四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
453 lines
20 KiB
Python
453 lines
20 KiB
Python
#!/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)")
|