#!/usr/bin/env python3 # -*- coding: utf-8 -*- """《守塔人》SYN 程序合成音效(批次 4,21-keeper.md §5.2 全清单 17 件)。 批次 4 口径(制作人裁定):设计文档 §5.2 标注 SA3 的条目全部以程序合成兜底落地 (任务书「sfx 按设计文档清单写 tools/gen__syn.py 程序合成」;纹理类风声/唱片 底噪/引擎程序合成可控性更好,且零 GPU 占用不与并行泳道争卡)。 清单(17 件): loop(游戏层 buffer.loop 持句柄,只去直流不淡化,FFT 整周期噪声保证零接缝): amb_storm 风暴底噪(现在时签名①;分幕 gain 0.5/1.0/0.7 由游戏层控制) amb_room 塔内底噪(炉火 crackle + 雨打玻璃 + 木构轻响) amb_vinyl 老唱片底噪(回忆时签名②;插叙期间唯一底噪) amb_engine 阿澈侧渔船引擎(通话期对侧 amb,voice 在播时游戏层 -6dB) one-shot(首尾 8ms cos² + 去直流 + 真峰值 ≤-3dBFS 解码回读): memory_in 插叙进入:唱针落下 + 转速走稳 1.0s memory_out 插叙回返:唱针抬起 + 轻划 0.6s radio_tune 调频找台 3s(C1 首通/信号重连;静态垫 + 扫频哨音落台) static_burst 阿澈信号断续气口 2s(信息音,游戏层 idle 门) hull_crack 危机拍1:船板开裂 3s(前 2s 呻吟渐强 → 碎裂) wave_wall 危机拍2:浪墙 4s(前 2s 低鸣渐强 → 浪拍 + 隆隆尾) lamp_mech 灯座上发条 3s(棘轮click渐紧 + 簧片吱呀 + 收锤clunk) hatch_open 灯座暗格开启 2s(金属刮擦 + 木滑 + 开启clunk) foghorn 雾角两长一短 4s(b_rain 承诺音:开场/幕间×2/ep_shore 恰四处) radio_beep 电台接通确认 0.3s(双音上行,abyss link_beep 同款 620→930Hz) flash_click 手电尾盖两响(两次短金属 click,120ms 间隔) ui_confirm / ui_menu 菜单/确认(全局先例同 radio gen_sfx.py) 可复现:本脚本入库,一键出 web/keeper/audio/sfx/*.{ogg,m4a};中间 wav 在 tools/gen_keeper_syn_wav/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k。 loop 零接缝工艺:周期噪声走 FFT 整周期合成(任意采样点首尾连续);事件类 (crackle/crash/chug)包络在循环边界前衰减至零。 用法:.venv-tts/bin/python tools/gen_keeper_syn.py (纯 CPU,无 GPU 依赖) """ import os import numpy as np import soundfile as sf SR = 44100 HERE = os.path.dirname(os.path.abspath(__file__)) STAGING = os.path.join(HERE, "gen_keeper_syn_wav") DST = os.path.join(HERE, "..", "web", "keeper", "audio", "sfx") FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg" PEAK_SRC = 10 ** (-4.0 / 20) # 源归一 -4dBFS(有损编码过冲余量) PEAK_LIMIT = 10 ** (-3.0 / 20) # 解码回读上限 -3dBFS os.makedirs(STAGING, exist_ok=True) os.makedirs(DST, exist_ok=True) RNG = np.random.default_rng(20260914) # 固定 seed:可复现 def t(d): return np.arange(int(SR * d)) / SR def env_exp(d, attack, release): n = int(SR * d) e = np.ones(n) na, nr = max(1, int(SR * attack)), max(1, int(SR * release)) e[:na] = np.linspace(0, 1, na) e[-nr:] *= np.linspace(1, 0, nr) return e def periodic_noise(n, beta=0.0, lo_bin=1): """整周期噪声:频谱幅度 ~ f^-beta、随机相位,时域严格以 n 为周期(循环零接缝)。""" freqs = np.fft.rfftfreq(n, 1 / SR) mag = np.zeros_like(freqs) bins = np.arange(lo_bin, len(freqs)) mag[lo_bin:] = 1.0 / np.maximum(freqs[lo_bin:] / SR * n, 1) ** beta mag[lo_bin:] /= np.sqrt(np.sum(mag[lo_bin:] ** 2)) # 单位能量 phase = RNG.uniform(0, 2 * np.pi, len(freqs)) spec = mag * np.exp(1j * phase) x = np.fft.irfft(spec, n) x = x / (np.max(np.abs(x)) + 1e-9) return x.astype(np.float64) def lowpass_onepole(x, cutoff): a = 1 - np.exp(-2 * np.pi * cutoff / SR) y = np.empty_like(x) acc = 0.0 for i in range(len(x)): acc += a * (x[i] - acc) y[i] = acc return y def spectral_lowpass(x, cutoff): """频域砖墙低通:周期信号滤波后仍严格整周期(loop 零接缝专用)。""" n = len(x) bins = np.fft.rfft(x) freqs = np.fft.rfftfreq(n, 1 / SR) bins[freqs > cutoff] = 0 return np.fft.irfft(bins, n) def spectral_highpass(x, cutoff): n = len(x) bins = np.fft.rfft(x) freqs = np.fft.rfftfreq(n, 1 / SR) bins[freqs < cutoff] = 0 return np.fft.irfft(bins, n) def bp_noise(d, fc, q=1.0): """带通噪声(共振峰 fc/带宽 fc/q):白噪卷积指数衰减正弦。""" n = int(SR * d) x = RNG.standard_normal(n) tt = np.arange(n) / SR k = 2 * np.pi * fc / (fc / q) kern = np.exp(-tt * np.pi * fc / q) * np.sin(2 * np.pi * fc * tt) y = np.convolve(x, kern, mode="full")[:n] return y / (np.max(np.abs(y)) + 1e-9) def crackle(d, rate, decay=0.004, amp=1.0): """噼啪脉冲群:泊松分布 impulses × 短衰减 burst(保证边界前衰减完毕)。""" n = int(SR * d) y = np.zeros(n) pos = 0 while True: pos += int(RNG.exponential(1.0 / (rate / SR))) if pos >= n - int(SR * 0.02): break ln = int(SR * decay) burst = RNG.standard_normal(ln) * np.exp(-np.arange(ln) / (ln * 0.25)) end = min(n, pos + ln) y[pos:end] += burst[: end - pos] * amp return y def save(name, sig, peak_db=-4.0): """one-shot 立体声存:逐声道去直流 → 首尾 8ms cos² 淡入淡出 → 峰值归一 (默认 -4dBFS 留 MDCT 过冲余量;明亮瞬态件可传 peak_db 加深裕量)。""" if sig.ndim == 1: sig = np.stack([sig, sig], axis=1) sig = sig - np.mean(sig, axis=0, keepdims=True) nf = max(1, int(SR * 0.008)) fade = 0.5 * (1 - np.cos(np.pi * np.arange(nf) / nf)) sig = sig.astype(np.float64).copy() sig[:nf] *= fade[:, None] sig[-nf:] *= fade[::-1][:, None] sig = sig / (np.max(np.abs(sig)) + 1e-9) * (10 ** (peak_db / 20)) sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR) def save_loop(name, sig): """无缝 loop 立体声存:逐声道去直流 → 首尾边界静音口袋(头 80ms 淡入 / 尾 120ms 淡出,项目通行工艺:radio phone_ring / abyss amb_* 的 m4a 循环边界均落在静音上, AAC priming 伪影因而不可闻;底层内容经整周期合成保证循环点连续)→ 峰值归一 -4dBFS。 淡出口袋由 save_loop 统一施加,合成侧只保证内容在边界附近电平连续。""" if sig.ndim == 1: sig = np.stack([sig, sig], axis=1) sig = sig - np.mean(sig, axis=0, keepdims=True) sig = sig.astype(np.float64).copy() sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC # 次声高通在 numpy 侧做(频域砖墙,整周期友好):ffmpeg 的 biquad hp20 会在 # 尾部硬零区产生 100ms 级振铃(-24dB),频域滤波则无状态时序问题。 for c in range(sig.shape[1]): sig[:, c] = spectral_highpass(sig[:, c], 20) sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC nf_head = max(1, int(SR * 0.08)) nf_tail = max(1, int(SR * 0.12)) fade_in = 0.5 * (1 - np.cos(np.pi * np.arange(nf_head) / nf_head)) fade_out = 0.5 * (1 + np.cos(np.pi * np.arange(nf_tail) / nf_tail)) sig[:nf_head] *= fade_in[:, None] sig[-nf_tail:] *= fade_out[:, None] # 硬零兜底:首 45ms / 末 30ms 置零(编码器时频窗在淡出末端会涂抹出 -40dB 残余, # 数字零保证 QC -60dBFS 门限稳过;30ms 静音口袋在风雨床里不可闻)。 sig[: int(SR * 0.045)] = 0 sig[-int(SR * 0.03):] = 0 sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR) def encode(name, loop=False): """staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。 loop=True 追加零接缝校验:循环边界阶跃 <= 5×自然一阶差分中位数(无接缝突跳)。""" src = os.path.join(STAGING, name + ".wav") for ext, cmd in [("ogg", ["-c:a", "libvorbis", "-q:a", "4"]), ("m4a", ["-c:a", "aac", "-b:a", "128k"])]: out = os.path.join(DST, f"{name}.{ext}") subprocess_args = [FFMPEG, "-y", "-v", "error", "-i", src] if not loop: # one-shot: hp30 抑制 MDCT/AAC 解码直流再生;loop 的次声高通已在 # save_loop 内经频域砖墙完成(ffmpeg biquad 会在循环尾硬零区振铃) subprocess_args += ["-af", "highpass=f=30"] subprocess_args += ["-ar", "44100", *cmd, out] import subprocess subprocess.run(subprocess_args, check=True) chk = "/tmp/_keeper_sfx_qc.wav" import subprocess as sp sp.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True) y, sr = sf.read(chk, always_2d=True) tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9) # 逐声道峰值 assert tp <= -3.0, f"{name}.{ext}: 真峰值 {tp:.2f}dBFS 超限" assert abs(float(np.mean(y.mean(axis=1)))) <= 0.001, f"{name}.{ext}: 直流超限" if loop: mono = y.mean(axis=1) # 零接缝判据(radio phone_ring 同款):解码后首/末 5ms 落静音(≤ -50dBFS)—— # 循环边界两侧静音,AAC priming 伪影不可闻;内容连续性由合成侧整周期保证。 edge_db = -50.0 gate = 10 ** (edge_db / 20) head5 = float(np.max(np.abs(mono[: int(sr * 0.005)]))) tail5 = float(np.max(np.abs(mono[-int(sr * 0.005):]))) assert head5 <= gate, f"{name}.{ext}: 循环头 5ms 非静音 {20 * np.log10(head5 + 1e-9):.1f}dB" assert tail5 <= gate, f"{name}.{ext}: 循环尾 5ms 非静音 {20 * np.log10(tail5 + 1e-9):.1f}dB" print(f" loop {y.shape[0] / sr:.2f}s 边界静音 ✓") print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS") import subprocess # noqa: E402 (encode 内引用,保持顶部无依赖歧义) # ================================================================ loop 四件 # ---- amb_storm:风暴底噪 10s(风 = 棕噪整周期 + 阵风整周期 LFO;远浪每 3.3s 一涌,3 涌整除循环)---- _d = 10.0 _n = int(SR * _d) _wind = periodic_noise(_n, beta=1.2) _wind = spectral_lowpass(_wind, 400) _k = 2 # 阵风 0.2Hz 整周期 _gust = 0.55 + 0.45 * np.cos(2 * np.pi * _k * np.arange(_n) / _n + 0.7) _storm = _wind * _gust for _i in range(3): # 3 涌 × 3.3s,包络涌起后指数衰减至零(边界干净) _off = int(SR * 3.3 * _i) _ln = int(SR * 3.3) _tt = np.arange(_ln) / SR _sw = np.exp(-((_tt - 0.55) ** 2) / 0.08) * np.exp(-np.maximum(_tt - 0.55, 0) * 1.1) _crash = bp_noise(3.3, 220, q=0.8)[:_ln] * _sw _storm[_off:_off + _ln] += _crash * 0.9 save_loop("amb_storm", _storm) # ---- amb_room:塔内底噪 8s(雨点玻璃 = 2-6k 带通整周期噪声床;炉火 crackle 8Hz;木构轻响 2 声)---- _d = 8.0 _n = int(SR * _d) _rain = periodic_noise(_n, beta=0.3) # 简易带通:差分+平滑近似 2-6kHz 亮色 _rain = np.diff(_rain, prepend=0) _rain = spectral_lowpass(_rain, 6000) _rain = _rain / (np.max(np.abs(_rain)) + 1e-9) _room = _rain * 0.35 + crackle(_d, rate=8, decay=0.003, amp=0.5) * 0.5 for _p in (1.7, 5.2): # 木构轻响:低品吱呀 _off = int(SR * _p) _ln = int(SR * 0.35) _tt = np.arange(_ln) / SR _creak = np.sin(2 * np.pi * (70 + 30 * _tt) * _tt) * np.exp(-_tt * 9) * 0.25 _room[_off:_off + _ln] += _creak save_loop("amb_room", _room) # ---- amb_vinyl:老唱片底噪 6s(密纹噼啪 + 轻嘶声 + 极轻转盘脉动 1Hz)---- _d = 6.0 _n = int(SR * _d) _hiss = periodic_noise(_n, beta=0.15) _hiss = spectral_lowpass(_hiss, 7000) _vinyl = _hiss * 0.18 + crackle(_d, rate=22, decay=0.0022, amp=0.8) * 0.8 _vinyl += 0.05 * np.sin(2 * np.pi * np.arange(_n) / SR) * 0 # 直流位零占位(去直流在 save_loop) _th = 1 + 0.15 * np.sin(2 * np.pi * 6 * np.arange(_n) / _n) # 6 转/循环轻脉动 _vinyl = _vinyl * _th save_loop("amb_vinyl", _vinyl) # ---- amb_engine:小渔船引擎 4s(6Hz 柴噪脉冲 24 整周期 + 舱内水晃低频晃)---- _d = 4.0 _n = int(SR * _d) _chug = np.zeros(_n) for _i in range(24): _off = int(_n * (_i + 0.5) / 24) _ln = int(SR * 0.09) _tt = np.arange(_ln) / SR _pulse = (np.sin(2 * np.pi * 55 * _tt) + 0.5 * np.sin(2 * np.pi * 110 * _tt)) * np.exp(-_tt * 45) _end = min(_n, _off + _ln) _chug[_off:_end] += _pulse[: _end - _off] _slosh = periodic_noise(_n, beta=1.5) _slosh = spectral_lowpass(_slosh, 150) _engine = _chug * 0.8 + _slosh * 0.5 * (0.6 + 0.4 * np.sin(2 * np.pi * 3 * np.arange(_n) / _n)) save_loop("amb_engine", _engine) # ================================================================ one-shot 十三件 # ---- memory_in:唱针落下 1.0s(落针 click+闷响 → 导入纹噪声起转 → 转速走稳)---- _d = 1.0 _tt = t(_d) _y = np.zeros(int(SR * _d)) _ln = int(SR * 0.02) _y[:_ln] += RNG.standard_normal(_ln) * np.exp(-np.arange(_ln) / (_ln * 0.15)) * 0.9 # 落针 _y[int(SR * 0.02):int(SR * 0.06)] += np.sin(2 * np.pi * 90 * t(0.04)) * env_exp(0.04, 0.002, 0.02) * 0.5 _run = bp_noise(0.9, 900, q=0.7) * (np.linspace(0.1, 1.0, int(SR * 0.9)) ** 0.6) * 0.35 _y[int(SR * 0.1):] += _run _y[int(SR * 0.1):] += crackle(0.9, rate=18, decay=0.002, amp=0.35) save("memory_in", _y) # ---- memory_out:唱针抬起 0.6s(轻划 zip → 抬起 click)---- _d = 0.6 _y = np.zeros(int(SR * _d)) _zip = bp_noise(0.25, 1800, q=2.0) * np.linspace(1.0, 0.1, int(SR * 0.25)) * 0.5 _y[: int(SR * 0.25)] += _zip _off = int(SR * 0.3) _ln = int(SR * 0.015) _y[_off:_off + _ln] += RNG.standard_normal(_ln) * np.exp(-np.arange(_ln) / (_ln * 0.2)) * 0.8 save("memory_out", _y) # ---- radio_tune:调频找台 3s(静态垫 + 三段扫频哨音找台 + 落台弱信号稳态)---- _d = 3.0 _y = bp_noise(_d, 1500, q=0.4) * 0.35 for _st, _f0, _f1 in ((0.2, 500, 900), (0.9, 1200, 700), (1.6, 800, 1050)): _seg = t(0.5) _swp = np.sin(2 * np.pi * (_f0 + (_f1 - _f0) * _seg / 0.5) * _seg) * env_exp(0.5, 0.05, 0.15) * 0.22 _y[int(SR * _st): int(SR * _st) + len(_swp)] += _swp _tail = t(0.8) _y[int(SR * 2.2):] += np.sin(2 * np.pi * 720 * _tail) * np.linspace(0, 0.25, len(_tail)) * env_exp(0.8, 0.3, 0.3) save("radio_tune", _y) # ---- static_burst:信号断续 2s(电台静电涌动 + 断裂缺口,气口信息音)---- _d = 2.0 _n = int(SR * _d) _env = np.ones(_n) for _g0, _g1 in ((0.45, 0.62), (1.25, 1.4)): _env[int(SR * _g0): int(SR * _g1)] = 0.06 _env *= np.exp(-np.maximum(t(_d) - 1.5, 0) * 2.5) * (1 - np.exp(-t(_d) * 8)) _y = bp_noise(_d, 2000, q=0.35) * _env * 0.7 _y += bp_noise(_d, 500, q=0.6) * _env * 0.25 save("static_burst", _y) # ---- hull_crack:船板开裂 3s(前 2s 船体呻吟渐强 → 碎裂群 + 低鸣尾)---- _d = 3.0 _y = np.zeros(int(SR * _d)) _moan = bp_noise(2.0, 120, q=6.0) * np.linspace(0.05, 1.0, int(SR * 2.0)) ** 2 * 0.5 _y[: int(SR * 2.0)] += _moan RNG2 = np.random.default_rng(7) for _p in (2.02, 2.12, 2.2, 2.35, 2.5): # 碎裂群 _off = int(SR * _p) _ln = int(SR * 0.06) _tt = np.arange(_ln) / SR _cr = (RNG2.standard_normal(_ln)) * np.exp(-np.arange(_ln) / (_ln * 0.2)) _res = np.sin(2 * np.pi * 140 * _tt) * np.exp(-_tt * 30) * 0.8 _end = min(len(_y), _off + _ln) _y[_off:_end] += (_cr * 0.5 + _res)[: _end - _off] _tail = t(0.7) _y[int(SR * 2.3): int(SR * 2.3) + len(_tail)] += bp_noise(0.7, 90, q=1.2)[: len(_tail)] * np.exp(-_tail * 3) * 0.6 save("hull_crack", _y) # ---- wave_wall:浪墙 4s(前 2s 深海低鸣渐强 → 浪拍崩落 + 隆隆远尾)---- _d = 4.0 _y = np.zeros(int(SR * _d)) _rise = periodic_noise(int(SR * 2.0), beta=1.4) _rise = lowpass_onepole(_rise, 220) * np.linspace(0.03, 1.0, int(SR * 2.0)) ** 2 * 0.8 _y[: int(SR * 2.0)] += _rise _crash = bp_noise(1.2, 300, q=0.7) * np.exp(-t(1.2) * 2.2) _y[int(SR * 2.0): int(SR * 2.0) + len(_crash)] += _crash * 1.0 _splash = bp_noise(0.8, 2500, q=0.4) * np.exp(-t(0.8) * 4) _y[int(SR * 2.05): int(SR * 2.05) + len(_splash)] += _splash * 0.35 _tail = t(1.6) _y[int(SR * 2.4): int(SR * 2.4) + len(_tail)] += lowpass_onepole( periodic_noise(int(SR * 1.6), beta=1.2), 180)[: len(_tail)] * np.exp(-_tail * 2.2) * 0.7 save("wave_wall", _y) # ---- lamp_mech:灯座上发条 3s(棘轮 click 渐紧 12 响 + 簧片吱呀 + 收锤 clunk)---- _d = 3.0 _y = np.zeros(int(SR * _d)) _periods = np.linspace(0.30, 0.17, 12) # 越上越紧(周期渐短) _acc = 0.35 for _i, _per in enumerate(_periods): _acc += _per _off = int(SR * _acc) if _off >= int(SR * 2.75): break _ln = int(SR * 0.05) _tt = np.arange(_ln) / SR _clk = (RNG.standard_normal(_ln) * 0.6 + np.sin(2 * np.pi * 2600 * _tt) * 0.4) * np.exp(-np.arange(_ln) / (_ln * 0.12)) _end = min(len(_y), _off + _ln) _y[_off:_end] += _clk[: _end - _off] * 0.8 _sq_off = _off - int(SR * _per * 0.45) # 回齿吱呀 if _sq_off > 0: _sl = int(SR * 0.08) _st = np.arange(_sl) / SR _sq = np.sin(2 * np.pi * (300 + 200 * _st / 0.08) * _st) * env_exp(0.08, 0.02, 0.05) * 0.12 _y[_sq_off:_sq_off + _sl] += _sq _off = int(SR * 2.8) # 收锤 clunk _ln = int(SR * 0.08) _tt = np.arange(_ln) / SR _y[_off:_off + _ln] += (np.sin(2 * np.pi * 180 * _tt) + 0.6 * np.sin(2 * np.pi * 360 * _tt)) * np.exp(-_tt * 35) * 0.9 save("lamp_mech", _y) # ---- hatch_open:暗格开启 2s(金属刮擦 → 木滑 → 开启 clunk)---- _d = 2.0 _y = np.zeros(int(SR * _d)) _scrape = bp_noise(0.7, 1400, q=3.0) * np.linspace(0.1, 1.0, int(SR * 0.7)) * 0.4 _y[: int(SR * 0.7)] += _scrape _slide = bp_noise(0.6, 350, q=2.5) * env_exp(0.6, 0.1, 0.3) * 0.5 _y[int(SR * 0.7): int(SR * 0.7) + len(_slide)] += _slide _off = int(SR * 1.45) _ln = int(SR * 0.1) _tt = np.arange(_ln) / SR _y[_off:_off + _ln] += (np.sin(2 * np.pi * 140 * _tt) + 0.5 * np.sin(2 * np.pi * 420 * _tt)) * np.exp(-_tt * 30) * 0.9 save("hatch_open", _y, peak_db=-8.0) # 金属 clunk 瞬态亮,-4dB 余量不足(实测过冲 -2.2dBFS) # ---- foghorn:雾角两长一短 4s(98Hz 基频 + 低通铜管感,承诺音)---- _d = 4.0 _y = np.zeros(int(SR * _d)) def _blast(y, st, d): n = int(SR * d) tt = np.arange(n) / SR vib = 1 + 0.006 * np.sin(2 * np.pi * 5.2 * tt) horn = np.zeros(n) for h, a in ((1, 1.0), (2, 0.55), (3, 0.3), (4, 0.12)): horn += a * np.sin(2 * np.pi * 98 * h * tt * vib) horn = lowpass_onepole(horn, 700) env = env_exp(d, 0.08, 0.25) off = int(SR * st) y[off:off + n] += horn / (np.max(np.abs(horn)) + 1e-9) * env * 0.85 _blast(_y, 0.05, 1.0) _blast(_y, 1.45, 1.0) _blast(_y, 2.8, 0.6) save("foghorn", _y, peak_db=-8.0) # 铜管雾角谐波簇瞬态亮,-4dB 余量不足 # ---- radio_beep:电台接通双音上行 0.3s(abyss link_beep 同款 620→930Hz)---- d1, d2 = 0.12, 0.16 _a = np.sin(2 * np.pi * 620 * t(d1)) * env_exp(d1, 0.004, 0.02) _b = np.sin(2 * np.pi * 930 * t(d2)) * env_exp(d2, 0.004, 0.06) save("radio_beep", np.concatenate([_a, np.zeros(int(SR * 0.02)), _b])) # ---- flash_click:手电尾盖两响(8ms 金属 click ×2,间隔 120ms)---- def _click(): n = int(SR * 0.03) x = RNG.standard_normal(n) * np.exp(-np.arange(n) / (n * 0.1)) x += np.sin(2 * np.pi * 3400 * np.arange(n) / SR) * np.exp(-np.arange(n) / (n * 0.08)) * 0.5 return x save("flash_click", np.concatenate([_click(), np.zeros(int(SR * 0.12)), _click(), np.zeros(int(SR * 0.04))])) # ---- ui_confirm:菜单确认(全局先例 880->1320Hz 双音)---- d1, d2 = 0.09, 0.14 a = np.sin(2 * np.pi * 880 * t(d1)) * env_exp(d1, 0.005, 0.003) b = np.sin(2 * np.pi * 1320 * t(d2)) * env_exp(d2, 0.005, 0.012) save("ui_confirm", np.concatenate([a, np.zeros(int(SR * 0.02)), b, np.zeros(int(SR * 0.05))])) # ---- ui_menu:菜单呼出(全局先例 330+495Hz 软音)---- d = 0.35 x = t(d) save("ui_menu", np.concatenate([ (np.sin(2 * np.pi * 330 * x) + 0.5 * np.sin(2 * np.pi * 495 * x)) * env_exp(d, 0.02, 0.08), np.zeros(int(SR * 0.05))])) # ================================================================ 落盘 for _n2 in ["amb_storm", "amb_room", "amb_vinyl", "amb_engine"]: encode(_n2, loop=True) for _n2 in ["memory_in", "memory_out", "radio_tune", "static_burst", "hull_crack", "wave_wall", "lamp_mech", "hatch_open", "foghorn", "radio_beep", "flash_click", "ui_confirm", "ui_menu"]: encode(_n2) print("keeper SYN sfx done (17 files x2 formats)")