# -*- coding: utf-8 -*- r'''把语音播报改成**多条方案降级**,修 Win7 上用不了的问题。 == 用户反馈 == "我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用" == 原来只有一条路,而且有几个 Win7 特有的坑 == powershell -NoProfile -WindowStyle Hidden -Command "Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')" 坑一:**`powershell` 只按名字找** Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。 精简版系统、或者从服务/计划任务拉起时 PATH 里可能没有 —— 找不到就**静默失败**(Popen 不报错,因为进程起来了,只是在里面失败)。 坑二:**Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`** PS 3.0 起才按 UTF-16 处理。所以中文经 `-Command` 传进去在 PS 2.0 上 很容易乱码 —— 表现是"念出来是乱码"或者干脆不念。 坑三:**`System.Speech` 需要 .NET Framework 3.0+** 而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有, **根本不需要 .NET**。绕开 .NET 更稳。 == 现在的做法:降级链 == 按"依赖越少越优先"排: 1. **cscript + VBScript + SAPI.SpVoice** VBScript 从 Windows 98 就有,SAPI5 是系统组件 —— 两个都不需要装东西,也不受 PowerShell 版本 / PATH / .NET 影响。 **这是最可能在 Win7 上通的一条。** 2. **wscript + VBScript + SAPI.SpVoice** 同上,换宿主(cscript 万一被安全软件拦了)。 3. **powershell(绝对路径)+ SAPI.SpVoice COM** 绕开 .NET,但仍要 PowerShell。 4. **powershell(绝对路径)+ System.Speech** 原来的做法,只是改用绝对路径。 5. **powershell(按 PATH)+ System.Speech** 最原始的做法,留作最后兜底。 **一条通了就记住它**(模块级缓存),后面都直接走那条 —— 不然每播一次都要把上面几条全试一遍,慢且难看。 == 顺手改进 == * `say()` 原来用 `Popen` 不等待 —— **失败了也返回"已播报"**。 现在**等它结束并看退出码**,真失败会明确报错,不再骗人。 代价是播报变成了同步的(要等念完)—— 但"报成功其实没出声" 比"慢一点"糟糕得多。 * VBScript 文件用 **mbcs(ANSI)** 编码写。写成 UTF-8 的话 cscript 读出来是乱码 —— 这是 VBScript 的老规矩。 ''' from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent ACTIONS = ROOT / "windows" / "lanremote" / "actions.py" # ---------------------------------------------------------------- 要替换的旧代码 OLD_VOICES = ''' 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()]''' OLD_SAY = ''' 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 '默认音色'})"''' NEW = ''' # ------------------------------------------------------------------ # 语音播报(系统自带 TTS) # ------------------------------------------------------------------ # # ⚠ 这里有一套**降级链**,不是随便写的。原因见文件末尾的说明, # 简单说:Win7 上的 PowerShell 2.0 + 中文 + PATH 三样都可能出问题, # 而 cscript + SAPI5 是最老最稳的组合(从 XP 就有)。 # # 顺序:cscript -> wscript -> ps(绝对路径)+SAPI COM # -> ps(绝对路径)+System.Speech -> ps(PATH)+System.Speech def _powershell_path(self) -> str: """找 powershell.exe。**先按绝对路径**,PATH 只作兜底。 为什么:Win7 上 PATH 里不一定有,而 CreateProcess 按名字找不到时 是**静默失败**的(进程根本没起来,但 Popen 不报错)。 """ import shutil as _sh cand = Path(os.environ.get("SystemRoot", r"C:\\Windows")) / \\ "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe" if os.name == "nt" and cand.is_file(): return str(cand) return _sh.which("powershell") or "" def _write_vbs(self, text: str, voice: str, rate: int) -> Path: """生成一个播报用的 .vbs。 ⚠ 必须用 **mbcs(系统 ANSI 代码页)** 写 —— cscript 读 .vbs 是按 ANSI 读的,写成 UTF-8 中文就是乱码。 """ import tempfile lines = ['Dim v', 'Set v = CreateObject("SAPI.SpVoice")'] if voice: # 按描述匹配音色。找不到就 silently 用默认 —— # 音色选不上不该让整条播报失败。 lines.append('Dim i') lines.append('For i = 0 To v.GetVoices().Count - 1') lines.append(' If InStr(v.GetVoices().Item(i).GetDescription(), "%s") > 0 Then' % voice.replace('"', '""')) lines.append(' Set v.Voice = v.GetVoices().Item(i)') lines.append(' End If') lines.append('Next') if rate: # SAPI 的 Rate 范围是 -10..10,跟我们的设置一致 lines.append("v.Rate = %d" % max(-10, min(10, rate))) # 长文本 VBScript 单行有长度限制,所以**分句拼接** chunk = text.replace('"', '""').replace("\\r", " ").replace("\\n", " ") while chunk: piece, chunk = chunk[:400], chunk[400:] lines.append('v.Speak "%s"' % piece) path = Path(tempfile.gettempdir()) / "ykxmz_say.vbs" path.write_bytes(("\\r\\n".join(lines) + "\\r\\n").encode("mbcs", errors="replace")) return path def _tts_commands(self, text: str, voice: str, rate: int) -> list: """按"依赖越少越优先"排出所有可行方案,返回 [(名字, 命令行)]。""" cmds = [] vbs = None try: vbs = self._write_vbs(text, voice, rate) except Exception: # noqa: BLE001 vbs = None if vbs is not None: # 1 / 2:完全不依赖 PowerShell 和 .NET cmds.append(("cscript", ["cscript", "//nologo", str(vbs)])) cmds.append(("wscript", ["wscript", "//nologo", str(vbs)])) ps = self._powershell_path() esc = text.replace("'", "''") if ps: # 3:绕开 .NET,只用系统原生 SAPI5 sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"] if voice: sapi.append("foreach ($o in $v.GetVoices()) { " "if ($o.GetDescription() -like '*%s*') { " "$v.Voice = $o } };" % voice.replace("'", "''")) if rate: sapi.append("$v.Rate = %d;" % max(-10, min(10, rate))) sapi.append("$v.Speak('%s')" % esc) cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden", "-Command", "".join(sapi)])) # 4:原来的做法,但用绝对路径 sp = ["Add-Type -AssemblyName System.Speech;", "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"] if voice: sp.append("try { $s.SelectVoice('%s') } catch { };" % voice.replace("'", "''")) if rate: sp.append("$s.Rate = %d;" % max(-10, min(10, rate))) sp.append("$s.Speak('%s')" % esc) cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle", "Hidden", "-Command", "".join(sp)])) # 5:最原始的(PATH + System.Speech),兜底 import shutil as _sh if _sh.which("powershell"): sp = ["Add-Type -AssemblyName System.Speech;", "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"] if voice: sp.append("try { $s.SelectVoice('%s') } catch { };" % voice.replace("'", "''")) if rate: sp.append("$s.Rate = %d;" % max(-10, min(10, rate))) sp.append("$s.Speak('%s')" % esc) cmds.append(("ps-path", ["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", "".join(sp)])) return cmds def list_voices(self) -> list[str]: """列出系统可用的 TTS 音色。 用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没有 .NET 3.0 的话 System.Speech 那条路是列不出来的,而 SAPI5 一定有。 """ if not IS_WINDOWS: return [] out = [] ps = self._powershell_path() if ps: script = ("$v = New-Object -ComObject SAPI.SpVoice;" "$v.GetVoices() | ForEach-Object { $_.GetDescription() }") try: proc = subprocess.run( [ps, "-NoProfile", "-Command", script], capture_output=True, timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), encoding="mbcs", errors="replace") out = [ln.strip() for ln in (proc.stdout or "").splitlines() if ln.strip()] except Exception: # noqa: BLE001 out = [] if not out: # 退到 System.Speech 再列一次 if ps: script = ("Add-Type -AssemblyName System.Speech;" "(New-Object System.Speech.Synthesis" ".SpeechSynthesizer).GetInstalledVoices()" " | ForEach-Object { $_.VoiceInfo.Name }") try: proc = subprocess.run( [ps, "-NoProfile", "-Command", script], capture_output=True, timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), encoding="mbcs", errors="replace") out = [ln.strip() for ln in (proc.stdout or "").splitlines() if ln.strip()] except Exception: # noqa: BLE001 out = [] return out def say(self, text: str, voice: str = "", rate: int = 0) -> str: """语音播报。voice 为空则用系统默认(或设置里的默认音色)。 ⚠ 会**等播报结束**并看退出码,失败就抛错。 原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。 代价是同步等待(念多久等多久),但"报成功其实没出声" 比"慢一点"糟糕得多。 """ 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)) cmds = self._tts_commands(text, chosen, speed) if not cmds: raise ActionError("这台电脑上找不到可用的语音播报方式" "(没有 cscript 也没有 powershell?)") # 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍 cached = globals().get("_TTS_LAST_OK") if cached: cmds.sort(key=lambda c: 0 if c[0] == cached else 1) tried = [] for name, cmd in cmds: try: proc = subprocess.run( cmd, capture_output=True, timeout=180, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), encoding="mbcs", errors="replace") except FileNotFoundError: tried.append("%s(没有这个程序)" % name) continue except subprocess.TimeoutExpired: tried.append("%s(超时)" % name) continue except Exception as exc: # noqa: BLE001 tried.append("%s(%s)" % (name, type(exc).__name__)) continue if proc.returncode == 0: globals()["_TTS_LAST_OK"] = name return "已播报(%s,用 %s)" % (chosen or "默认音色", name) err = ((proc.stderr or "") + (proc.stdout or "")).strip() tried.append("%s(退出码%d %s)" % (name, proc.returncode, err[:40])) raise ActionError("语音播报失败,试过:" + ";".join(tried))''' NEW_VOICES = ''' def list_voices(self) -> list[str]: """列出系统可用的 TTS 音色。 用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话 System.Speech 那条路列不出来,而 SAPI5 一定有。 """ if not IS_WINDOWS: return [] ps = self._powershell_path() if not ps: return [] out = [] for script in ( # 优先 SAPI5 COM "$v = New-Object -ComObject SAPI.SpVoice;" "$v.GetVoices() | ForEach-Object { $_.GetDescription() }", # 退到 System.Speech "Add-Type -AssemblyName System.Speech;" "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }", ): try: proc = subprocess.run( [ps, "-NoProfile", "-Command", script], capture_output=True, timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), encoding="mbcs", errors="replace") out = [ln.strip() for ln in (proc.stdout or "").splitlines() if ln.strip()] except Exception: # noqa: BLE001 out = [] if out: return out return []''' def main() -> int: print("=" * 58) print(" 语音播报:改成多条方案降级") print("=" * 58) print() raw = ACTIONS.read_bytes() bom = raw.startswith(b"\xef\xbb\xbf") t = raw.decode("utf-8-sig") if "_tts_commands" in t: print(" 已经改过了") return 0 missing = [k for k, v in {"list_voices": OLD_VOICES, "say": OLD_SAY}.items() if v not in t] if missing: print(" ✗ 找不到:%s" % ", ".join(missing)) return 1 print(" ✓ 锚点就位") t = t.replace(OLD_VOICES, NEW_VOICES, 1) t = t.replace(OLD_SAY, NEW, 1) ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8")) print(" ✓ list_voices 改用 SAPI5 COM(+ System.Speech 兜底)") print(" ✓ say 改成降级链 + 等待退出码 + 记住可用的那条") return 0 if __name__ == "__main__": sys.exit(main())