Files
SoundGame/tools/gen_keeper_syn.py
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

453 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""《守塔人》SYN 程序合成音效(批次 4,21-keeper.md §5.2 全清单 17 件)。
批次 4 口径(制作人裁定):设计文档 §5.2 标注 SA3 的条目全部以程序合成兜底落地
(任务书「sfx 按设计文档清单写 tools/gen_<slug>_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)")