2.1.0:网站自动抢救 + 后台抢救报告分区 + 网络热门音效 + 全量测试后台静默

按用户定的规矩,这一轮有新功能,升第二位:2.0.0 -> 2.1.0。

== 一、网站挂了会自己救 ==

用户原话:「网站还是容易挂,建议继续增加隧道稳定性,并在后台设立
检测单元,实时检测网站状态,一旦检测到网站挂了,立即启动紧急预案」。

新增 relay/monitor.sh + lanremote-monitor.timer:
    每 60 秒从**公网**查一次 /v1/health(不是查 127.0.0.1)

    为什么不查本机:本机 200 只说明中转进程活着,用户真正关心的是
    经过 Cloudflare 边缘 + 隧道 + 本机三段之后能不能打开。实测过
    隧道两条实例都掉了、本机 curl 照样 200,只有从公网测才发现 502。

    连续 3 次不通才动手(这条 4G 线路本来就有 2~4% 偶发失败),
    救完 10 分钟内不再动手(否则预案刚重启完隧道还没连上,
    下一次检查又救一遍,来回重启反而永远连不上)。

新增 relay/rescue.sh:五级阶梯,每级做完立刻复验,好了就停
    1 重启隧道          不动中转,在线设备不掉线
    2 重启中转服务      设备要重连,但很快
    3 重新施加网络调优  MSS / MTU 跑偏时用
    4 重启无线网卡      4G / WiFi 卡死时用
    5 重启整机          最后的底牌,**默认不开**(要 RESCUE_REBOOT=1)

    理由:如果问题出在供电或上游线路,重启只会让恢复更慢;
    而且半夜自动重启一台没人管的机器风险太大。

== 二、抢救报告 + 后台分区 ==

用户原话:「如果紧急预案未能解决,则将问题打包成 .txt,然后在
中转站设置里面加一个分区专门放还有下载这个 txt。解决后将解决问题
的方法加入紧急预案」。

    * 五级都没救回来 -> 把现场(内存/进程/隧道连接数/路由和 MTU、
      三份日志尾部、内核异常)整理成 rescue_reports/<时间戳>.txt
    * 后台新增「抢救报告」分区,列出报告(带结论摘要),点一下下载
    * 下载走单独的 /rescue/<名字>,**不发缓存头**(内容会变,
      也不该让 Cloudflare 边缘留着)。和 /download/ 分开是有意的:
      那个是给用户的安装包,文件名带版本号、内容永不改变,
      所以敢发 604800 长缓存
    * **要登录才能下** —— 报告里有内网 IP、进程表、日志尾部,
      是运维信息。第一版把这条路由插在 _admin_gate() 之前,
      等于匿名可下,自己查出来补上了
    * 每次自动抢救成功,把「第几级奏效」写进 rescue_knowledge.md;
      监测单元读它,下次同类症状优先试历史上管用的那一级
      (这就是用户说的"解决后将解决问题的方法加入紧急预案")

== 三、网络热门音效 ==

用户原话:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈,
各种老牧师音效,可以直接远程播放」。

**版权说明**:网上那些「冰冰冰」「啊哈哈哈」是**有版权的真实录音**
(影视片段 / 别人的作品),不能下载打包进安装包 —— 那既侵权,
也会让这个项目在 Gitee 那边直接踩线(这个仓库已经被判过一次
"内容可能含有违规信息")。

所以:
    * 内置 8 个**程序合成**的音效(正弦/方波现场算,零版权风险、
      不占安装包体积):冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 /
      警报 / 鼓点 / 弹簧。名字对着感觉来,但不是那些录音本身
    * 做「自己放进来」的目录:mp3/wav 丢进 data/sfx/ 就自动出现在
      列表里,照样能远程播。目录里放了一份说明
    * 手表首页新增「音效」按钮,点开选一个,电脑就响
      (复用现成的 showChooser,不新建面板)

协议加 sfx_list / sfx_play 两条命令。

== 四、修「安卓设置页一片空白」 ==

用户报的。根因:设置页**建好了但忘了 root.addView()** ——
showOnly() 让它 VISIBLE 也没用,它根本不在视图树里。

这类"漏挂"编译完全不报,只在运行时表现为空白。已加断言
_tests/test_watch_panels_wired.py 盯着:每个面板都必须挂到 root、
并且纳入 showOnly()。

== 五、全量测试改成后台静默 ==

用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。

会开窗口的 6 个阶段(含开**全屏**那个)现在跑在独立的 Windows
隐藏桌面上(_tests/hidden_desktop.py)。窗口是**真的** —— 全屏、
可见、几何、Win32 枚举都真实,测试有效性一点没损失 ——
只是不在用户眼前,全屏也不会锁屏。

顺带修了一个一直在骗人的检测:判断"锁屏"时只看 OpenInputDesktop
成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。
于是截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了
(用户电脑锁了一夜,我就是被这个误导过)。改成看输入桌面的**名字**。
现在锁屏也能跑全量:该 SKIP 的 SKIP,该跑的照跑。

== 六、顺手修的真 bug ==

* windows/lanremote/server.py 的 sfx_play 分支用了不存在的变量名 body
  (应该是 msg)-> **一调就 NameError**。pyflakes 那关抓出来的,
  说明"未定义名"这条检查有用。
* _build/package_remote.py 的文件白名单只有 .py/.sh/.ico,
  **.service/.timer 收不进服务端 zip** -> 新机器上监测根本不启动,
  而且不报错、只是静默不跑。已加。
* protocol.py 被我补丁插入了 LF 行到 CRLF 文件里 -> 行尾混用,编码审计红。
* deploy.sh 的 watchdog 块被我改锚点时吃掉了 start/say 两行
  (bash -n 通过、只是少两条执行语句)—— 改完读回来核对才发现。
* _build/CHANGELOG.md 的 2.1.0 说明里写了"管理后台",
  触发对外材料清洁度检查。改成不提管理页(用户定的规矩:
  对外只能提官网)。

全量回归 46 个阶段全部通过。
This commit is contained in:
xmz7891
2026-10-08 18:36:50 +08:00
parent 50f4e1fb6f
commit 3f18e6bb40
203 changed files with 6645 additions and 1996 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。"""
__version__ = "2.0.0"
__version__ = "2.1.0"
APP_NAME = "ykxmz"
+1085 -1061
View File
File diff suppressed because it is too large Load Diff
+181 -179
View File
@@ -1,179 +1,181 @@
# -*- coding: utf-8 -*-
"""通信协议常量与编解码。
设计目标:双端零外部依赖。
* Windows 端:Python 标准库 socket + json
* 安卓端:java.net.Socket + 系统自带 org.json
传输格式:TCP 长连接,每条消息一行 UTF-8 JSON,以 \\n 结尾。
二进制负载(图片/音频/文件)用 base64 分块传输。
"""
from __future__ import annotations
import base64
import json
DEFAULT_PORT = 8765
PROTOCOL_VERSION = 1
MAX_LINE_BYTES = 8 * 1024 * 1024 # 单行上限 8MB(约 6MB 原始数据)
CHUNK_SIZE = 256 * 1024
# 单次传输(文件 / 图片 / 音频)的总体积上限
MAX_TOTAL_BYTES = 256 * 1024 * 1024 # 分块大小 256KB
# ---- 客户端 -> 服务端 命令 ----
CMD_PAIR = "pair"
CMD_AUTH = "auth"
CMD_PING = "ping"
CMD_IMAGE = "image"
CMD_AUDIO = "audio"
CMD_MEDIA = "media"
CMD_CLIP_SET = "clip_set"
CMD_CLIP_GET = "clip_get"
CMD_POWER = "power"
CMD_RUN = "run"
CMD_SAY = "say"
CMD_VOICE_LIST = "voice_list"
CMD_SET_VOICE = "set_voice"
CMD_AUDIO_LIST = "audio_list"
CMD_PLAY_LOCAL = "play_local"
CMD_SHUTDOWN_TIMER = "shutdown_timer" # 定时关机 / 取消
CMD_MONITOR = "monitor" # 关闭 / 打开显示器
CMD_OPEN_URL = "open_url" # 在电脑浏览器打开网址
CMD_QUICK = "quick" # 快捷启动程序
CMD_OPEN_FOLDER = "open_folder" # 打开常用文件夹
CMD_PROC_LIST = "proc_list" # 进程列表
CMD_PROC_KILL = "proc_kill" # 结束进程
CMD_BATTERY = "battery" # 电量
CMD_DISK = "disk" # 磁盘占用
CMD_PERF = "perf" # CPU / 内存占用
CMD_CLIP_HISTORY = "clip_history" # 剪贴板历史
CMD_SET_WALLPAPER = "set_wallpaper" # 把收到的图设为壁纸
CMD_SAY_CLIP = "say_clip" # 朗读剪贴板
CMD_FEATURE_LIST = "feature_list" # 电脑端能做哪些功能
CMD_FEATURE_RUN = "feature_run" # 执行某个功能
CMD_AUDIO_PAUSE = "audio_pause" # 暂停电脑正在播的音频
CMD_AUDIO_RESUME = "audio_resume" # 继续播放
CMD_AUDIO_STATUS = "audio_status" # 当前播放状态
CMD_NOTIFY = "notify"
CMD_FILE_PUSH = "file_push"
CMD_FILE_LIST = "file_list"
CMD_FILE_PULL = "file_pull"
CMD_INFO = "info"
CMD_SCREEN_START = "screen_start"
CMD_SCREEN_STOP = "screen_stop"
CMD_SCREENSHOT = "screenshot"
# ---- 服务端 -> 客户端 事件 ----
EVT_FILE = "file"
EVT_CLIP = "clip"
EVT_STATUS = "status"
EVT_LOG = "log"
EVT_FRAME = "frame"
EVT_SCREEN_STOPPED = "screen_stopped"
MEDIA_ACTIONS = {
"playpause": 0xB3,
"next": 0xB0,
"prev": 0xB1,
"stop": 0xB2,
"volup": 0xAF,
"voldown": 0xAE,
"mute": 0xAD,
}
POWER_ACTIONS = {"shutdown", "restart", "lock", "sleep", "abort"}
# 手表端会用到的中文动作名(便于 UI 直接显示)
MEDIA_LABELS = {
"playpause": "播放/暂停",
"next": "下一曲",
"prev": "上一曲",
"stop": "停止",
"volup": "音量+",
"voldown": "音量-",
"mute": "静音",
}
POWER_LABELS = {
"shutdown": "关机",
"restart": "重启",
"lock": "锁屏",
"sleep": "睡眠",
"abort": "取消关机",
}
def dumps(obj: dict) -> bytes:
"""编码成一行 JSON(UTF-8 + 换行)。"""
return (json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
def loads(line: bytes) -> dict:
"""解析一行 JSON。"""
return json.loads(line.decode("utf-8", errors="replace").strip())
def b64encode(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def b64decode(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"))
def chunk_bytes(data: bytes, size: int = CHUNK_SIZE):
"""把字节流切成若干块,产出 (seq, is_last, chunk)。"""
if not data:
yield 0, True, b""
return
total = (len(data) + size - 1) // size
for index in range(total):
piece = data[index * size:(index + 1) * size]
yield index, index == total - 1, piece
class ChunkTooLarge(Exception):
"""分块累积超过上限,或顺序不对。"""
class ChunkAssembler:
"""接收端:按 seq 收集分块,拼回完整数据。
MAX_LINE_BYTES 只管单行,累积体积必须另外设上限 ——
否则已配对设备可以一直发 chunk 把内存吃干(最后 bytes() 还要再翻一倍)。
"""
def __init__(self, limit: int = MAX_TOTAL_BYTES):
self._buf = bytearray()
self._expected = 0
self._limit = int(limit)
def feed(self, seq: int, is_last: bool, data: bytes) -> bytes | None:
"""返回完整数据(当收到最后一块时),否则 None。
顺序不对或超限时抛 ChunkTooLarge,不静默拼出坏文件。
"""
if seq == 0:
self._buf = bytearray()
self._expected = 0
if seq != self._expected:
self._buf = bytearray()
self._expected = 0
raise ChunkTooLarge(
f"分块顺序不对(收到 {seq},应为 {self._expected})")
if len(self._buf) + len(data) > self._limit:
self._buf = bytearray()
self._expected = 0
raise ChunkTooLarge(f"文件超过上限({self._limit // 1048576} MB)")
self._buf.extend(data)
self._expected = seq + 1
if is_last:
result = bytes(self._buf)
self._buf = bytearray()
self._expected = 0
return result
return None
def reset(self) -> None:
self._buf = bytearray()
self._expected = 0
# -*- coding: utf-8 -*-
"""通信协议常量与编解码。
设计目标:双端零外部依赖。
* Windows 端:Python 标准库 socket + json
* 安卓端:java.net.Socket + 系统自带 org.json
传输格式:TCP 长连接,每条消息一行 UTF-8 JSON,以 \\n 结尾。
二进制负载(图片/音频/文件)用 base64 分块传输。
"""
from __future__ import annotations
import base64
import json
DEFAULT_PORT = 8765
PROTOCOL_VERSION = 1
MAX_LINE_BYTES = 8 * 1024 * 1024 # 单行上限 8MB(约 6MB 原始数据)
CHUNK_SIZE = 256 * 1024
# 单次传输(文件 / 图片 / 音频)的总体积上限
MAX_TOTAL_BYTES = 256 * 1024 * 1024 # 分块大小 256KB
# ---- 客户端 -> 服务端 命令 ----
CMD_PAIR = "pair"
CMD_AUTH = "auth"
CMD_PING = "ping"
CMD_IMAGE = "image"
CMD_AUDIO = "audio"
CMD_MEDIA = "media"
CMD_CLIP_SET = "clip_set"
CMD_CLIP_GET = "clip_get"
CMD_POWER = "power"
CMD_RUN = "run"
CMD_SAY = "say"
CMD_VOICE_LIST = "voice_list"
CMD_SET_VOICE = "set_voice"
CMD_AUDIO_LIST = "audio_list"
CMD_SFX_LIST = "sfx_list" # 列出可用音效(内置 + 用户自己放的)
CMD_SFX_PLAY = "sfx_play" # 播放某个音效
CMD_PLAY_LOCAL = "play_local"
CMD_SHUTDOWN_TIMER = "shutdown_timer" # 定时关机 / 取消
CMD_MONITOR = "monitor" # 关闭 / 打开显示器
CMD_OPEN_URL = "open_url" # 在电脑浏览器打开网址
CMD_QUICK = "quick" # 快捷启动程序
CMD_OPEN_FOLDER = "open_folder" # 打开常用文件夹
CMD_PROC_LIST = "proc_list" # 进程列表
CMD_PROC_KILL = "proc_kill" # 结束进程
CMD_BATTERY = "battery" # 电量
CMD_DISK = "disk" # 磁盘占用
CMD_PERF = "perf" # CPU / 内存占用
CMD_CLIP_HISTORY = "clip_history" # 剪贴板历史
CMD_SET_WALLPAPER = "set_wallpaper" # 把收到的图设为壁纸
CMD_SAY_CLIP = "say_clip" # 朗读剪贴板
CMD_FEATURE_LIST = "feature_list" # 电脑端能做哪些功能
CMD_FEATURE_RUN = "feature_run" # 执行某个功能
CMD_AUDIO_PAUSE = "audio_pause" # 暂停电脑正在播的音频
CMD_AUDIO_RESUME = "audio_resume" # 继续播放
CMD_AUDIO_STATUS = "audio_status" # 当前播放状态
CMD_NOTIFY = "notify"
CMD_FILE_PUSH = "file_push"
CMD_FILE_LIST = "file_list"
CMD_FILE_PULL = "file_pull"
CMD_INFO = "info"
CMD_SCREEN_START = "screen_start"
CMD_SCREEN_STOP = "screen_stop"
CMD_SCREENSHOT = "screenshot"
# ---- 服务端 -> 客户端 事件 ----
EVT_FILE = "file"
EVT_CLIP = "clip"
EVT_STATUS = "status"
EVT_LOG = "log"
EVT_FRAME = "frame"
EVT_SCREEN_STOPPED = "screen_stopped"
MEDIA_ACTIONS = {
"playpause": 0xB3,
"next": 0xB0,
"prev": 0xB1,
"stop": 0xB2,
"volup": 0xAF,
"voldown": 0xAE,
"mute": 0xAD,
}
POWER_ACTIONS = {"shutdown", "restart", "lock", "sleep", "abort"}
# 手表端会用到的中文动作名(便于 UI 直接显示)
MEDIA_LABELS = {
"playpause": "播放/暂停",
"next": "下一曲",
"prev": "上一曲",
"stop": "停止",
"volup": "音量+",
"voldown": "音量-",
"mute": "静音",
}
POWER_LABELS = {
"shutdown": "关机",
"restart": "重启",
"lock": "锁屏",
"sleep": "睡眠",
"abort": "取消关机",
}
def dumps(obj: dict) -> bytes:
"""编码成一行 JSON(UTF-8 + 换行)。"""
return (json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
def loads(line: bytes) -> dict:
"""解析一行 JSON。"""
return json.loads(line.decode("utf-8", errors="replace").strip())
def b64encode(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def b64decode(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"))
def chunk_bytes(data: bytes, size: int = CHUNK_SIZE):
"""把字节流切成若干块,产出 (seq, is_last, chunk)。"""
if not data:
yield 0, True, b""
return
total = (len(data) + size - 1) // size
for index in range(total):
piece = data[index * size:(index + 1) * size]
yield index, index == total - 1, piece
class ChunkTooLarge(Exception):
"""分块累积超过上限,或顺序不对。"""
class ChunkAssembler:
"""接收端:按 seq 收集分块,拼回完整数据。
MAX_LINE_BYTES 只管单行,累积体积必须另外设上限 ——
否则已配对设备可以一直发 chunk 把内存吃干(最后 bytes() 还要再翻一倍)。
"""
def __init__(self, limit: int = MAX_TOTAL_BYTES):
self._buf = bytearray()
self._expected = 0
self._limit = int(limit)
def feed(self, seq: int, is_last: bool, data: bytes) -> bytes | None:
"""返回完整数据(当收到最后一块时),否则 None。
顺序不对或超限时抛 ChunkTooLarge,不静默拼出坏文件。
"""
if seq == 0:
self._buf = bytearray()
self._expected = 0
if seq != self._expected:
self._buf = bytearray()
self._expected = 0
raise ChunkTooLarge(
f"分块顺序不对(收到 {seq},应为 {self._expected})")
if len(self._buf) + len(data) > self._limit:
self._buf = bytearray()
self._expected = 0
raise ChunkTooLarge(f"文件超过上限({self._limit // 1048576} MB)")
self._buf.extend(data)
self._expected = seq + 1
if is_last:
result = bytes(self._buf)
self._buf = bytearray()
self._expected = 0
return result
return None
def reset(self) -> None:
self._buf = bytearray()
self._expected = 0
+18
View File
@@ -515,6 +515,24 @@ class RemoteServer:
return {"ok": True, "voices": voices,
"current": str(self.store.get("tts_voice") or "")}
if cmd == P.CMD_SFX_LIST:
items = self.actions.list_sfx()
# 同时给一个扁平的 names —— 手表端的 stringList(reply, key)
# 只认字符串数组,和 audio_list 的约定保持一致。
return {"ok": True, "sfx": items, "count": len(items),
"names": [it.get("name", "") for it in items],
"dir": self.actions.sfx_dir_hint()}
if cmd == P.CMD_SFX_PLAY:
name = str(msg.get("name") or "").strip()
if not name:
return {"ok": False, "error": "没说要放哪个音效"}
try:
msg = self.actions.play_sfx(name)
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "message": msg, "name": name}
if cmd == P.CMD_AUDIO_LIST:
files = self.actions.list_local_audio()
# 顺手给出扁平的 names/paths,手表端不用再解析嵌套结构
+414
View File
@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
r'''网络热门音效 —— 手表上点一下,电脑就响。
============================================================================
先说清楚一件事(版权)
============================================================================
用户要的是「冰冰冰」「啊哈哈哈」「老牧师」这些**网上流行的真实录音**。
那些录音都是有版权的(有的是影视片段、有的是别人的作品),
**我不能把它们下载下来打包进安装包** —— 那既侵权,也会让这个项目
在 Gitee / 应用商店那边直接踩线。
所以我做了两件事:
1. **内置几个程序合成的音效** —— 用正弦波/方波现场算出来的,
完全原创、零版权风险。名字和"感觉"对着用户说的那几个来,
但它们不是那些录音本身(也不冒充)。
(合成的好处:不用带二进制素材,安装包不变大,
而且每个音效就是几十行参数,想调随时能调。)
2. **做一个"自己放进来"的目录** ——
用户把真正的素材(mp3/wav)丢进 data/sfx/ 就自动出现在列表里,
手表上照样能点。这样他想要真的一模一样的音效,
自己放进去就行,不用改代码。
目录路径会在日志和设置页里写出来,省得他找不到。
============================================================================
为什么不用 numpy
============================================================================
安装包把 numpy 排除了(--exclude-module numpy,为了省 20MB)。
所以波形全部用标准库的 math + struct 手算。16000Hz 单声道足够,
纯 Python 生成几秒的音频也就几十毫秒。
'''
from __future__ import annotations
import math
import struct
import wave
from pathlib import Path
SAMPLE_RATE = 16000
# 内置音效的"配方"。name -> (文件名, 生成函数)
# 生成函数返回 [-1.0, 1.0] 的浮点采样列表。
BUILTIN_SPECS: dict = {}
def _env(i: int, total: int, attack: float = 0.01, release: float = 0.25) -> float:
"""简包络:起音/收尾各淡入淡出一段,避免爆音(咔哒声)。"""
a = max(1, int(total * attack))
r = max(1, int(total * release))
if i < a:
return i / a
if i > total - r:
return max(0.0, (total - i) / r)
return 1.0
def _tone(freq: float, dur: float, vol: float = 0.6,
shape: str = "sine", duty: float = 0.5) -> list:
"""一段单音。shape: sine / square / saw / tri"""
n = int(SAMPLE_RATE * dur)
out = []
for i in range(n):
t = i / SAMPLE_RATE
ph = (freq * t) % 1.0
if shape == "square":
v = 1.0 if ph < duty else -1.0
elif shape == "saw":
v = 2.0 * ph - 1.0
elif shape == "tri":
v = 4.0 * abs(ph - 0.5) - 1.0
else:
v = math.sin(2 * math.pi * ph)
out.append(v * vol * _env(i, n, 0.02, 0.3))
return out
def _silence(dur: float) -> list:
return [0.0] * int(SAMPLE_RATE * dur)
def _mix(*tracks) -> list:
"""把几段混在一起(长度取最长的)。"""
longest = max(len(t) for t in tracks)
out = [0.0] * longest
for t in tracks:
for i, v in enumerate(t):
out[i] += v
# 防削顶
peak = max((abs(v) for v in out), default=1.0)
if peak > 0.95:
out = [v * 0.95 / peak for v in out]
return out
def _seq(*tracks) -> list:
"""把几段接起来。"""
out: list = []
for t in tracks:
out.extend(t)
return out
def _sweep(f0: float, f1: float, dur: float, vol: float = 0.6) -> list:
"""滑音(频率从 f0 滑到 f1)。"""
n = int(SAMPLE_RATE * dur)
out = []
phase = 0.0
for i in range(n):
freq = f0 + (f1 - f0) * (i / n)
phase += freq / SAMPLE_RATE
out.append(math.sin(2 * math.pi * phase) * vol * _env(i, n, 0.01, 0.3))
return out
def _vibrato(freq: float, dur: float, depth: float = 0.06,
rate: float = 6.0, vol: float = 0.6) -> list:
"""带颤音的音,听感更"人声"一点。"""
n = int(SAMPLE_RATE * dur)
out = []
phase = 0.0
for i in range(n):
t = i / SAMPLE_RATE
f = freq * (1.0 + depth * math.sin(2 * math.pi * rate * t))
phase += f / SAMPLE_RATE
out.append(math.sin(2 * math.pi * phase) * vol * _env(i, n, 0.03, 0.35))
return out
# ===========================================================================
# 内置音效的配方
# ===========================================================================
#
# ⚠ 这些是**程序合成**的原创音效,不是网上那些录音本身。
# 名字对着用户提到的感觉来,方便他认,但不是冒充。
def _mk_bing() -> list:
"""「冰冰冰」—— 三下清亮的高音,一下比一下亮。
用正弦 + 一点三次谐波,听感接近"叮"那种金属味。
"""
def one(freq: float, dur: float) -> list:
a = _tone(freq, dur, 0.5, "sine")
b = _tone(freq * 3, dur, 0.12, "sine") # 三次谐波 -> 金属感
return _mix(a, b)
return _seq(one(1180, 0.13), _silence(0.05),
one(1320, 0.13), _silence(0.05),
one(1480, 0.22))
def _mk_haha() -> list:
"""「啊哈哈哈」—— 下滑的颤音,三声,越来越低。
模拟笑声的节奏:起音高、往下滑、带颤。
"""
def one(f0: float, f1: float, dur: float) -> list:
return _sweep(f0, f1, dur, 0.55)
return _seq(
one(420, 330, 0.16), _silence(0.03),
one(400, 300, 0.17), _silence(0.03),
one(380, 250, 0.30))
def _mk_preacher() -> list:
"""「老牧师」—— 低沉、缓慢、带回声的庄严感。
低音方波(管风琴那种味道)+ 一段延迟很长的回声。
"""
base = _tone(110, 0.5, 0.45, "square", 0.35)
base = _seq(base, _tone(146, 0.5, 0.45, "square", 0.35))
base = _seq(base, _tone(110, 0.9, 0.45, "square", 0.35))
# 回声:把原声衰减 0.35 倍、延迟 0.28 秒叠上去
delay = int(SAMPLE_RATE * 0.28)
echo = [0.0] * delay + [v * 0.35 for v in base]
return _mix(base, echo)
def _mk_ding() -> list:
"""「叮」—— 一声干净的提示音。"""
a = _tone(1760, 0.7, 0.45, "sine")
b = _tone(1760 * 2.76, 0.7, 0.10, "sine")
return _mix(a, b)
def _mk_dong() -> list:
"""「当当当」—— 三下闷响。"""
return _seq(_tone(196, 0.18, 0.6, "tri"), _silence(0.08),
_tone(196, 0.18, 0.6, "tri"), _silence(0.08),
_tone(165, 0.4, 0.6, "tri"))
def _mk_alarm() -> list:
"""「警报」—— 两高一低的急促音。"""
return _seq(_tone(880, 0.16, 0.55, "square", 0.5), _silence(0.04),
_tone(660, 0.16, 0.55, "square", 0.5), _silence(0.04),
_tone(880, 0.16, 0.55, "square", 0.5), _silence(0.04),
_tone(660, 0.30, 0.55, "square", 0.5))
def _mk_drum() -> list:
"""「鼓点」—— 低频下滑的"咚",四下。"""
def hit(dur: float = 0.22) -> list:
return _sweep(180, 55, dur, 0.75)
return _seq(hit(), _silence(0.06), hit(), _silence(0.06),
hit(), _silence(0.06), hit(0.35))
def _mk_boing() -> list:
"""「弹簧」—— 上下摆动的滑音。"""
up = _sweep(200, 900, 0.16, 0.55)
down = _sweep(900, 260, 0.22, 0.5)
return _seq(up, down)
BUILTIN_SPECS = {
# 中文名 : (文件名, 生成函数, 一句说明)
"冰冰冰": ("冰冰冰.wav", _mk_bing, "三下清亮的高音,越走越高"),
"啊哈哈哈": ("啊哈哈哈.wav", _mk_haha, "下滑的颤音,三声"),
"老牧师": ("老牧师.wav", _mk_preacher, "低沉庄严,带长回声"),
"叮": ("叮.wav", _mk_ding, "一声干净提示音"),
"当当当": ("当当当.wav", _mk_dong, "三下闷响"),
"警报": ("警报.wav", _mk_alarm, "两高一低的急促音"),
"鼓点": ("鼓点.wav", _mk_drum, "四下低频咚"),
"弹簧": ("弹簧.wav", _mk_boing, "上下摆动的滑音"),
}
# 用户自己放进来的素材认这些格式
USER_EXTS = (".wav", ".mp3", ".m4a", ".aac", ".ogg", ".wma", ".flac")
class SfxError(Exception):
pass
class Sfx:
"""音效库。放哪、有什么、放哪个。"""
def __init__(self, data_dir: Path, player=None):
# ⚠ data_dir 是**被控端自己的数据目录**(settings.json 边上),
# 不是仓库目录 —— 打包成 exe 之后仓库目录是只读的临时目录,
# 往那儿写文件重启就没了。
self.dir = Path(data_dir) / "sfx"
self._player = player # 外面传进来的播放函数(复用 MCI)
self._built = False
# ---------------------------------------------------------------- 内置
def ensure_builtin(self) -> int:
"""首次运行时把内置音效生成为 WAV。返回新建了几个。
⚠ 用**清单文件**记住"哪些是生成过的",而不是简单地
"文件不存在就生成"。
为什么:用户删掉某个内置音效,多半是不想要它。
如果每次启动都按"不存在就补"的逻辑跑,他删一次我就补一次,
等于删不掉 —— 实测自己写的测试就是这么判红的。
但也不能"只要生成过一次就永远不补":万一第一次生成失败
(磁盘满、权限问题),下次启动该重试。
所以用清单:生成成功的才记进去。清单里有、文件没了
= 用户删的,不再补;清单里没有 = 还没成功过,重试。
"""
self.dir.mkdir(parents=True, exist_ok=True)
manifest_file = self.dir / ".builtin.txt"
done: set = set()
try:
if manifest_file.is_file():
done = {ln.strip() for ln in
manifest_file.read_text(encoding="utf-8").splitlines()
if ln.strip()}
except OSError:
done = set()
made = 0
for name, (filename, fn, _desc) in BUILTIN_SPECS.items():
if filename in done:
continue # 生成过 —— 文件没了就是用户删的
target = self.dir / filename
if target.is_file():
done.add(filename) # 早先版本生成的,补登记
continue
try:
_write_wav(target, fn())
done.add(filename)
made += 1
except Exception: # noqa: BLE001
# 某个音效生成失败不该让整个功能挂掉;
# 也不记进清单,下次启动会重试
continue
try:
manifest_file.write_text("\n".join(sorted(done)) + "\n",
encoding="utf-8")
except OSError:
pass
# 放一个说明文件,省得用户找不到往哪放自己的素材
readme = self.dir / "放这里.txt"
if not readme.is_file():
readme.write_text(
"把你自己的音效文件(mp3 / wav / m4a / ogg)直接放进这个目录,\n"
"手表那边刷新一下就能看到、能点了。\n"
"\n"
"文件名就是列表里显示的名字。比如把素材存成:\n"
" 冰冰冰.mp3\n"
" 啊哈哈哈.mp3\n"
" 老牧师.mp3\n"
"\n"
"目录里的 冰冰冰.wav / 啊哈哈哈.wav / 老牧师.wav 这几个\n"
"是程序**合成**的版本(不是网上那些录音)——\n"
"因为那些录音有版权,不能打包进安装包。\n"
"想要一模一样的,把真素材放进来覆盖同名文件即可。\n"
"\n"
"不想要的音效直接删掉即可,不会自己长回来。\n",
encoding="utf-8")
self._built = True
return made
# ---------------------------------------------------------------- 列表
def list_sfx(self) -> list:
"""列出可用音效。内置的排前面,用户自己放的排后面。"""
if not self._built:
self.ensure_builtin()
items: list = []
seen: set = set()
def add(name: str, path: Path, builtin: bool) -> None:
key = name.lower()
if key in seen:
return
seen.add(key)
try:
size = path.stat().st_size
except OSError:
size = 0
items.append({
"name": name,
"file": path.name,
"size": size,
"builtin": builtin,
"ext": path.suffix.lower().lstrip("."),
})
# 内置的(按 BUILTIN_SPECS 的顺序,名字好听好记)
for name, (filename, _fn, _desc) in BUILTIN_SPECS.items():
p = self.dir / filename
if p.is_file():
add(name, p, True)
# 用户自己放的
try:
for p in sorted(self.dir.iterdir()):
if not p.is_file():
continue
if p.suffix.lower() not in USER_EXTS:
continue
if p.name == "放这里.txt":
continue
add(p.stem, p, False)
except OSError:
pass
return items
# ---------------------------------------------------------------- 播放
def resolve(self, name: str) -> Path:
"""把用户给的名字解析成一个文件路径。找不到就抛错。"""
wanted = (name or "").strip()
if not wanted:
raise SfxError("没说要放哪个音效")
# 先按"显示名"找(内置的)
for name2, (filename, _fn, _desc) in BUILTIN_SPECS.items():
if name2 == wanted or filename == wanted:
p = self.dir / filename
if p.is_file():
return p
# 再按文件名/stem 找(用户自己放的)
for p in self.dir.iterdir() if self.dir.is_dir() else []:
if not p.is_file():
continue
if p.suffix.lower() not in USER_EXTS:
continue
if p.name == wanted or p.stem == wanted:
return p
raise SfxError(f"没有这个音效:{wanted}")
def play(self, name: str) -> str:
"""播放一个音效。"""
path = self.resolve(name)
if self._player is None:
raise SfxError("播放器没接上(内部错误)")
return self._player(str(path))
def path_hint(self) -> str:
"""给用户看的一句"素材放哪"。"""
return str(self.dir)
def _write_wav(path: Path, samples: list) -> None:
"""把 [-1,1] 的浮点采样写成 16 位单声道 WAV。"""
frames = bytearray()
for v in samples:
if v > 1.0:
v = 1.0
elif v < -1.0:
v = -1.0
frames += struct.pack("<h", int(v * 32767))
with wave.open(str(path), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SAMPLE_RATE)
w.writeframes(bytes(frames))
+2 -2
View File
@@ -27,12 +27,12 @@ VSVersionInfo(
[
StringStruct("CompanyName", "ykxmz"),
StringStruct("FileDescription", "ykxmz"),
StringStruct("FileVersion", "2.0.0.0"),
StringStruct("FileVersion", "2.1.0.0"),
StringStruct("InternalName", "ykxmz"),
StringStruct("LegalCopyright", "ykxmz"),
StringStruct("OriginalFilename", "ykxmz.exe"),
StringStruct("ProductName", "ykxmz"),
StringStruct("ProductVersion", "2.0.0.0"),
StringStruct("ProductVersion", "2.1.0.0"),
],
)
]