mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 12:30:44 +00:00
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:
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""局域网遥控 · Windows 被控端。"""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.1.0"
|
||||
APP_NAME = "ykxmz"
|
||||
|
||||
+1085
-1061
File diff suppressed because it is too large
Load Diff
+181
-179
@@ -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
|
||||
|
||||
@@ -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,手表端不用再解析嵌套结构
|
||||
|
||||
@@ -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))
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user