mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 18:20:43 +00:00
按用户定的规矩,这一轮有新功能,升第二位: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 个阶段全部通过。
415 lines
15 KiB
Python
415 lines
15 KiB
Python
# -*- 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))
|