# -*- coding: utf-8 -*- """新增后端能力: 1. TTS 音色可选(列出系统音色 + 指定音色/语速播报) 2. 播放本机指定音频文件(MP3 等),限定在音乐目录内 3. 投图窗口标题可自定义(设置里配模板,单次也可以覆盖) """ from __future__ import annotations import sys from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") PROTOCOL = ROOT / "windows" / "lanremote" / "protocol.py" ACTIONS = ROOT / "windows" / "lanremote" / "actions.py" CONFIG = ROOT / "windows" / "lanremote" / "config.py" # ---------------------------------------------------------------- protocol PROTO_OLD = 'CMD_SAY = "say"\n' PROTO_NEW = ('CMD_SAY = "say"\n' 'CMD_VOICE_LIST = "voice_list"\n' 'CMD_AUDIO_LIST = "audio_list"\n' 'CMD_PLAY_LOCAL = "play_local"\n') # ------------------------------------------------------------------ config CONFIG_OLD = ' "image_custom_height": 0, # 自定义高度(像素),0=自动\n' CONFIG_NEW = (' "image_custom_height": 0, # 自定义高度(像素),0=自动\n' ' "image_window_title": "", # 投图窗口标题模板,可用 {name};空=默认\n' ' "tts_voice": "", # 默认 TTS 音色,空=系统默认\n' ' "tts_rate": 0, # 语速 -10~10\n' ' "mp3_dir": "", # 本机音乐目录,空=自动找 音乐/Music\n') # ----------------------------------------------------------------- actions OLD_SAY = ''' def say(self, text: str) -> str: if not text.strip(): raise ActionError("播报内容为空") if not IS_WINDOWS: raise ActionError("语音播报目前仅支持 Windows") safe = text.replace("'", "''") script = ( "Add-Type -AssemblyName System.Speech;" "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Speak('{safe}')" ) try: subprocess.Popen( ["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", script], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) except Exception as exc: raise ActionError(f"TTS 启动失败: {exc}") from exc return "已播报" ''' NEW_SAY = ''' def list_voices(self) -> list[str]: """列出系统可用的 TTS 音色。""" if not IS_WINDOWS: return [] script = ( "Add-Type -AssemblyName System.Speech;" "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }" ) try: proc = subprocess.run( ["powershell", "-NoProfile", "-Command", script], capture_output=True, timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), encoding="mbcs", errors="replace") except Exception: return [] return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] def say(self, text: str, voice: str = "", rate: int = 0) -> str: """语音播报。voice 为空则用系统默认(或设置里的默认音色)。""" if not text.strip(): raise ActionError("播报内容为空") if not IS_WINDOWS: raise ActionError("语音播报目前仅支持 Windows") chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip() try: speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0) except (TypeError, ValueError): speed = 0 speed = max(-10, min(10, speed)) safe = text.replace("'", "''") parts = [ "Add-Type -AssemblyName System.Speech;", "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;", ] if chosen: parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} " "catch { };") if speed: parts.append(f"$s.Rate = {speed};") parts.append(f"$s.Speak('{safe}')") script = "".join(parts) try: subprocess.Popen( ["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", script], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) except Exception as exc: raise ActionError(f"TTS 启动失败: {exc}") from exc return f"已播报({chosen or '默认音色'})" # ------------------------------------------------------------------ # 播放本机音频文件(MP3 等) # ------------------------------------------------------------------ AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma") def audio_roots(self) -> list[Path]: """允许播放的目录。只认音乐相关目录,避免被用来播任意系统文件。""" roots: list[Path] = [] configured = str(self.store.get("mp3_dir") or "").strip() if configured: path = Path(configured) if path.is_dir(): roots.append(path) home = Path.home() for name in ("Music", "音乐", "Downloads", "下载"): path = home / name if path.is_dir(): roots.append(path) return roots def list_local_audio(self, limit: int = 300) -> list[dict]: """列出本机可播放的音频文件。""" items: list[dict] = [] seen: set[str] = set() for root in self.audio_roots(): try: paths = sorted(root.rglob("*")) except OSError: continue for path in paths: if len(items) >= limit: return items try: if not path.is_file(): continue if path.suffix.lower() not in self.AUDIO_EXTS: continue key = str(path).lower() if key in seen: continue seen.add(key) items.append({ "name": path.name, "path": str(path), "size": path.stat().st_size, "dir": root.name, }) except OSError: continue items.sort(key=lambda it: it["name"].lower()) return items def play_local(self, path: str) -> str: """播放本机某个音频文件。只允许音乐目录内的文件。""" target = Path(path) if not target.is_file(): raise ActionError(f"文件不存在: {path}") if target.suffix.lower() not in self.AUDIO_EXTS: raise ActionError(f"不支持的音频格式: {target.suffix}") allowed = False for root in self.audio_roots(): try: target.resolve().relative_to(root.resolve()) allowed = True break except (ValueError, OSError): continue if not allowed: raise ActionError("只允许播放音乐目录里的文件") self.play_audio(str(target)) return f"正在播放 {target.name}" ''' # 投图窗口标题 OLD_TITLE = ''' win = tk.Toplevel(self.ui.root) win.title(f"图片 - {p.name}")''' NEW_TITLE = ''' win = tk.Toplevel(self.ui.root) win.title(window_title)''' OLD_SHOW_HEAD = ''' def show_image(self, path: str) -> str: """显示图片。 方式由设置 image_display 决定:''' NEW_SHOW_HEAD = ''' def show_image(self, path: str, title: str = "") -> str: """显示图片。 title 为窗口标题(可留空,用设置里的模板;模板支持 {name})。 方式由设置 image_display 决定:''' NEW_SHOW_HEAD_END = ''' img.thumbnail(target, Image.LANCZOS) fullscreen = (mode == "fullscreen")''' NEW_SHOW_HEAD_END2 = ''' img.thumbnail(target, Image.LANCZOS) fullscreen = (mode == "fullscreen") # 窗口标题:单次指定 > 设置里的模板 > 默认 window_title = (title or "").strip() or str( self.store.get("image_window_title") or "").strip() or "图片 - {name}" try: window_title = window_title.replace("{name}", p.name) except Exception: window_title = f"图片 - {p.name}"''' def main() -> int: ok = True def patch(path: Path, pairs, label: str) -> None: nonlocal ok text = path.read_text(encoding="utf-8") for old, new, tag in pairs: if old not in text: print(f" [失败] {label} / {tag} 锚点未匹配") ok = False return text = text.replace(old, new, 1) path.write_text(text, encoding="utf-8") print(f" {label}: OK") patch(PROTOCOL, [(PROTO_OLD, PROTO_NEW, "新增命令")], "protocol.py") patch(CONFIG, [(CONFIG_OLD, CONFIG_NEW, "新增设置")], "config.py") patch(ACTIONS, [ (OLD_SAY, NEW_SAY, "音色+本机播放"), (OLD_SHOW_HEAD, NEW_SHOW_HEAD, "show_image 签名"), (NEW_SHOW_HEAD_END, NEW_SHOW_HEAD_END2, "窗口标题"), (OLD_TITLE, NEW_TITLE, "应用标题"), ], "actions.py") print("\n语法检查:") import ast for name, path in (("protocol.py", PROTOCOL), ("config.py", CONFIG), ("actions.py", ACTIONS)): try: ast.parse(path.read_text(encoding="utf-8")) print(f" {name}: OK") except SyntaxError as exc: print(f" {name}: 语法错误 {exc}") ok = False return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())