# -*- 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("