Files
xmz7891 fd8be87209 2.1.8:修 Win7 语音播报 + 快捷栏按钮做大且4格全能换
用户这一轮报了两个问题,都修了。

== 一、Win7 上语音播报不能用 ==

用户原话:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"

**原来只有一条路**:

    powershell -NoProfile -WindowStyle Hidden -Command
        "Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"

这在 Win7 上有三个坑,任何一个都能让它彻底不出声:

  1. **`powershell` 只按名字找**
     Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
     精简版系统、或从服务/计划任务拉起时 PATH 里可能没有 ——
     CreateProcess 按名字找不到时是**静默失败**的。

  2. **Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`**
     PS 3.0 起才按 UTF-16 处理。中文经 `-Command` 传进去容易乱码 ——
     表现是念出来是乱码,或者干脆不念。

  3. **`System.Speech` 需要 .NET Framework 3.0+**
     而系统自带的 **SAPI5** 从 Windows XP 就有,**根本不需要 .NET**。

**改成多方案降级**,按"依赖越少越优先"排:

    1. cscript + VBScript + SAPI5      <- Win98 起就有,不用装任何东西
    2. wscript + VBScript + SAPI5      <- 上一条被安全软件拦时的备选
    3. powershell + SAPI5 (COM)        <- 绕开 .NET
    4. powershell + System.Speech      <- 原来的做法,改用绝对路径
    5. powershell(PATH) + System.Speech <- 最原始的,兜底

一条通了**记住它**,以后直接用(不用每次从头试)。

**顺带修掉一个"骗人"的行为**:原来 `say()` 用 `Popen` 不等进程结束,
失败了也返回"已播报" —— 明明没出声却报成功。
现在等退出码,**全失败会明确报错并列出试过哪几条**。
代价是同步等待(念多久等多久),但"报成功其实没出声"比"慢一点"糟糕得多。

还加了 `_build/diag_tts.py`:在一台机器上把 5 条方案全试一遍并报告
哪条能用 —— 下次再有"某台机器上不行",跑一下就知道。

VBScript 的细节:**必须用 ANSI(mbcs) 写**,写成 UTF-8 的话
cscript 读出来中文是乱码(VBScript 的老规矩,很隐蔽)。
长文本还要分句 —— VBScript 单行有长度上限。

== 二、快捷栏按钮太小 + 4 格都要能换 ==

用户原话:"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点,
           然后呢,这个每每个四格算换一下吧,第一个第二个第三个
           第四个都能换东西"

  * **按钮做大**:30dp -> 46dp(接近 Android 建议的最小可点区域 48),
    字号 11 -> 14。30dp 在表盘上手指按上去一半在栏外。
    **栏高做成常量 QUICK_BAR_DP,栏高 / 面板 topMargin / addView
    三处用同一个值** —— 分三处写的话改一个忘一个,
    表现是"内容被盖住"或"上面空一条"。

  * **4 格全部可自定义**。用户最早说"其中有一个按钮不能改,
    功能就是一键返回桌面",第一版就按"第 4 格写死 desktop"做的。
    现在他改主意了 —— 所以 desktop 变成**普通可选动作**,
    默认仍放第 4 格(习惯不用改),但想换也能换。
    可选动作从 7 个变成 8 个。

  **教训(写进测试注释了)**:别把"用户当时的一句话"当成永久约束
  焊进结构里;真是约束的话,注释里要写清是什么时候的要求,
  下次他改主意时才知道该动哪儿。

== 三、修一个"测试自己没擦干净"造成的假 bug ==

全量回归报:

    [FAIL] 配对码正确写入  实际 '(旧的残留值)'

而 `(旧的残留值)` 是**上一次测试遗留**的配对码 —— 数据目录
`C:\ProgramData\ykxmz\data` 在卸载时**故意不删**(那里存着用户的
中转地址、配对码等,卸载不该删),所以机器上会留着上一轮的
settings.json。测试装的时候虽然传了 `/CODE=517284`,但环境没擦干净时
就可能读到旧值 —— 表现成"安装包有 bug"。

**修法**:`test_installer_cycle.py` 加一步"装前先清残留"
(正规卸载 -> 杀进程 -> 删安装目录 -> **删数据目录**)。
数据目录那一步是关键 —— 卸载器不删它。

== 四、其他 ==

  * `docs/项目规矩.md` 第一节改成**按预案执行**:
    预案一 = 做完测试没问题就上传;预案二 = 做完测试但不上传,直接给用户。
    用户没说预案几时默认按预案一,并在交付时说明。
  * 顺手把那份文档里的老域名 `relay.xxmxxs.top` 改成现在的
    `yk.xxmxxs.top`(老服务器早就退役了)。
  * 修掉自己造的重复定义:往 actions.py 插降级链时,
    `list_voices` / `say` 各插了两份(pyflakes 报 redefinition)。
  * 一次危险的误操作:`Path.write_text(..., newline='\n')` 会**先截断文件
    再校验参数**,参数非法时文件已经空了 —— 我用它把
    `_tests/test_quickbar.py` 清空了。已重写。
    **以后不给 write_text 传 newline。**

== 验证 ==

    全量回归(21 关)全部 PASS
    新增关卡:3a13 手表快捷栏 / 3a14 语音播报降级
    安装包周期 23 项全过(装 -> 验 -> 卸 -> 再验)
    Java 编译通过(android-34, --release 8)
    本机实测语音播报:cscript 那条通了(2.4 秒)
    全部产物重建,Win7 兼容校验通过
2026-10-09 23:30:05 +08:00

251 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
r'''诊断:这台机器上哪几种语音播报方案能用。
== 为什么写这个 ==
用户反馈:**Win7 上语音播报不能用**。
现在的实现只有一条路:
powershell -NoProfile -WindowStyle Hidden -Command
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
这在 Win7 上有几个可能致命的点:
1. **`powershell` 可能不在 PATH 里**
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
正常装的机器 PATH 里有,但被精简过、或者从服务/计划任务里拉起时
可能没有 —— 而**我们是用 CreateProcess 按名字找的**,找不到就静默失败。
2. **Win7 默认是 PowerShell 2.0,它用 ANSI 代码页解析 `-Command`**
PS 3.0+ 才改成按 UTF-16 处理命令行。所以中文经 `-Command` 传进去
在 PS 2.0 上会乱码 —— 表现是"念出来是乱码"或"什么都不念"。
3. **`System.Speech` 需要 .NET Framework 3.0+**
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
根本不需要 .NET。绕开 .NET 更稳。
== 所以这里试 5 条路 ==
A. powershell(按 PATH 找)+ System.Speech <- 现在的实现
B. powershell(按**绝对路径**找)+ System.Speech <- 修"不在 PATH"
C. powershell(绝对路径)+ SAPI.SpVoice COM <- 绕开 .NET
D. **cscript + VBScript + SAPI.SpVoice** <- 完全不依赖 PowerShell
E. wscript + VBScript + SAPI.SpVoice <- 同上,另一个宿主
D/E 最值得期待:**VBScript 从 Windows 98 就有,SAPI5 是系统组件**,
两个都不需要装任何东西,也不受 PowerShell 版本影响。
== 怎么跑 ==
python _build\diag_tts.py
它会一条条试并打印结果。**在有问题的机器上跑最有价值** ——
比如那台 Win7。把输出发回来就知道该换哪条路。
'''
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
IS_WINDOWS = os.name == "nt"
# 让它念一句中文。中文最能暴露编码问题 ——
# 纯英文在 PS 2.0 上也可能碰巧是对的(ASCII 不受代码页影响)。
TEXT = "语音播报测试,一二三"
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
FOUND: list = []
FAILED: list = []
def report(name: str, ok: bool, detail: str = "") -> None:
mark = "可用 " if ok else "不可用"
print(" [%s] %s%s" % (mark, name, (" —— " + detail) if detail else ""))
(FOUND if ok else FAILED).append(name)
def run(cmd: list, timeout: int = 30) -> tuple:
"""跑一条命令,返回 (退出码, 输出)。不让它弹窗。"""
try:
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
creationflags=NO_WINDOW,
encoding="mbcs", errors="replace")
out = ((p.stdout or "") + (p.stderr or "")).strip()
return p.returncode, out
except FileNotFoundError:
return -1, "文件找不到"
except subprocess.TimeoutExpired:
return -2, "超时"
except Exception as exc: # noqa: BLE001
return -3, "%s: %s" % (type(exc).__name__, exc)
def find_powershell() -> str:
"""先按 PATH 找,再按绝对路径找。"""
p = shutil.which("powershell")
if p:
return p
cand = Path(os.environ.get("SystemRoot", r"C:\Windows")) / \
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
return str(cand) if cand.is_file() else ""
def main() -> int:
print("=" * 62)
print(" 语音播报方案诊断")
print("=" * 62)
print()
if IS_WINDOWS:
try:
wv = sys.getwindowsversion()
print(" 系统: Windows %d.%d (build %d)"
% (wv.major, wv.minor, wv.build))
except Exception: # noqa: BLE001
print(" 系统: Windows(版本号取不到)")
else:
print(" 系统: 非 Windows")
print(" Python: %s (%d 位)" % (sys.version.split()[0],
64 if sys.maxsize > 2 ** 32 else 32))
print(" 测试文本: %r" % TEXT)
print()
if not IS_WINDOWS:
print(" 非 Windows,没有 TTS 方案可试。")
return 0
# ---------------- 0. 环境事实 ----------------
print("── 0. 环境 ──")
ps_path = find_powershell()
ps_in_path = shutil.which("powershell")
print(" powershell 在 PATH 里: %s" % (ps_in_path or "**没有**"))
print(" powershell 绝对路径: %s" % (ps_path or "**找不到**"))
if ps_path:
code, out = run([ps_path, "-NoProfile", "-Command",
"$PSVersionTable.PSVersion.Major"])
print(" PowerShell 主版本: %s" % (out.strip() or "查不到"))
print(" cscript: %s" % (shutil.which("cscript") or "**没有**"))
print(" wscript: %s" % (shutil.which("wscript") or "**没有**"))
print(" SystemRoot: %s" % os.environ.get("SystemRoot", "?"))
print()
# ---------------- A. 现有实现 ----------------
print("── A. powershell(按 PATH)+ System.Speech ← 现在产品用的 ──")
if not ps_in_path:
report("A", False, "powershell 不在 PATH 里 —— **这就是失败原因**")
else:
script = ("Add-Type -AssemblyName System.Speech;"
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
"$s.Speak('%s')" % TEXT.replace("'", "''"))
code, out = run(["powershell", "-NoProfile", "-WindowStyle", "Hidden",
"-Command", script])
report("A", code == 0, "退出码 %d %s" % (code, out[:70]))
time.sleep(0.4)
# ---------------- B. 绝对路径 + System.Speech ----------------
print("── B. powershell(绝对路径)+ System.Speech ──")
if not ps_path:
report("B", False, "找不到 powershell.exe")
else:
script = ("Add-Type -AssemblyName System.Speech;"
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
"$s.Speak('%s')" % TEXT.replace("'", "''"))
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
"-Command", script])
report("B", code == 0, "退出码 %d %s" % (code, out[:70]))
time.sleep(0.4)
# ---------------- C. SAPI.SpVoice COM(绕开 .NET)----------------
print("── C. powershell(绝对路径)+ SAPI.SpVoice COM ──")
if not ps_path:
report("C", False, "找不到 powershell.exe")
else:
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
"$v.Speak('%s')" % TEXT.replace("'", "''"))
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
"-Command", script])
report("C", code == 0, "退出码 %d %s" % (code, out[:70]))
time.sleep(0.4)
# ---------------- D. cscript + VBScript + SAPI ----------------
#
# 最值得期待的一条:VBScript 从 Windows 98 就有,
# SAPI5 是系统组件,**两个都不需要装任何东西**,
# 也完全不受 PowerShell 版本 / PATH / .NET 的影响。
print("── D. cscript + VBScript + SAPI.SpVoice(不依赖 PowerShell)──")
vbs = (
'Dim v\r\n'
'Set v = CreateObject("SAPI.SpVoice")\r\n'
'v.Speak "%s"\r\n'
) % TEXT.replace('"', '""')
# ⚠ VBScript 文件要 ANSI(=系统代码页) —— 写成 UTF-8 的话
# 中文在 cscript 里会变乱码。这里用 mbcs 编码写。
tmp = Path(tempfile.gettempdir()) / "ykxmz_tts_test.vbs"
try:
tmp.write_bytes(vbs.encode("mbcs", errors="replace"))
except Exception as exc: # noqa: BLE001
report("D", False, "写 vbs 失败: %s" % exc)
else:
code, out = run(["cscript", "//nologo", str(tmp)])
report("D", code == 0, "退出码 %d %s" % (code, out[:70]))
time.sleep(0.4)
# ---------------- E. wscript(不弹黑框)----------------
print("── E. wscript + VBScript + SAPI.SpVoice ──")
if tmp.is_file():
code, out = run(["wscript", "//nologo", str(tmp)])
report("E", code == 0, "退出码 %d %s" % (code, out[:70]))
else:
report("E", False, "没有 vbs 文件")
time.sleep(0.4)
# ---------------- 音色列表(两种方式对比)----------------
print()
print("── 可用音色 ──")
if ps_path:
code, out = run([
ps_path, "-NoProfile", "-Command",
"$v = New-Object -ComObject SAPI.SpVoice;"
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }"])
print(" SAPI5(COM):")
for ln in (out or "").splitlines():
if ln.strip():
print(" " + ln.strip())
if not out.strip():
print(" (列不出来)")
code2, out2 = run([
ps_path, "-NoProfile", "-Command",
"Add-Type -AssemblyName System.Speech;"
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"])
print(" System.Speech:")
for ln in (out2 or "").splitlines():
if ln.strip():
print(" " + ln.strip())
if not out2.strip():
print(" (列不出来 —— .NET 那套可能有问题)")
# ---------------- 结论 ----------------
print()
print("=" * 62)
print(" 可用 %d 条 / 不可用 %d 条" % (len(FOUND), len(FAILED)))
if FOUND:
print(" 可用方案: %s" % ", ".join(FOUND))
print()
print(" → 产品应该按**这个顺序**降级尝试:D(cscript) -> C(SAPI COM)")
print(" -> B(绝对路径) -> A(PATH),全失败才报错。")
else:
print(" ⚠ 一条都不通。可能是:没装任何语音、系统被精简过、")
print(" 或者 SAPI 组件损坏。")
print("=" * 62)
return 0
if __name__ == "__main__":
sys.exit(main())