#!/usr/bin/env python3 # -*- coding: utf-8 -*- """《回声地窟》SYN 程序合成音效(批次 1,16-dungeon.md §9.2 标注 SYN 的条目)。 17 个 id / 19 个文件(side_* 由 mono 源经 ffmpeg pan 派生为预 pan 立体声): pulse 声波脉冲:180->60Hz 正弦扫频 + 噪声瞬态,0.15s,中频突出(中频段 1-2kHz 补一层) mat_stone/door/stair/water 材质尾音:闷石敲/空心木咚/金属当啷/水滴湿滑,各 0.2-0.25s side_wall_l/r 侧向回声·墙(闷哒);side_open_l/r 侧向回声·开口(空旷回灵) —— 立体声文件能量只在 L 或 R 声道(引擎 play() 无实时 pan, space whoosh_left/right 同工艺;SA3 立体声分离度不达标时本 SYN 即兜底路径) step_own/turn bump 单步/拖步转身/撞墙闷响(脚步=低频 100-300Hz,与脉冲中频带分离防糊) coin/potion/gear 金币叮当/喝药咕咚/换装金属轻响 atk_swing/parry/hurt 挥拳风声/格挡亮叮/受击闷击 levelup 上行钟声琶音(内容音,允许;D 小调五声,与 rpg 的 A 小调区分) SA3 条目(echo_1..4、stairs_down、mob_step_*、tel_*、atk_hit、enemy_die)本轮跳过, 清单见 research/new8/batch1-assets-report.md。 可复现:本脚本入库,一键出 web/dungeon/audio/sfx/*.{ogg,m4a};中间 wav 在 gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k; one-shot 真峰值 <= -3dBFS(解码回读法,源归一 -4dBFS 留 MDCT 过冲余量);无 loop 类条目。 用法:.venv-tts/bin/python web/dungeon/gen/gen_sfx.py (纯 CPU,无 GPU 依赖) """ import os import subprocess import numpy as np import soundfile as sf SR = 44100 HERE = os.path.dirname(os.path.abspath(__file__)) STAGING = os.path.join(HERE, "staging", "sfx") DST = os.path.join(HERE, "..", "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) 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 lowpass(x, k): return np.convolve(x, np.ones(k) / k, mode="same") def bandpass(x, lo, hi): """简易带通:低通(hi) 减 低通(lo)。k = SR/(pi*fc) 近似窗长。""" kh, kl = max(1, int(SR / (np.pi * hi))), max(1, int(SR / (np.pi * lo))) return lowpass(x, kh) - lowpass(x, kl) def bell(freq, dur, decay=6.0, partials=((1, 1.0), (2.4, 0.35), (3.9, 0.15))): x = t(dur) return sum(a * np.sin(2 * np.pi * freq * r * x) * np.exp(-x * decay * (1 + r * 0.4)) for r, a in partials) def save(name, sig): """mono 存(side_* 供 pan 派生)。工艺同 tools/fix_clicks.py: 去直流(减均值)→ 首尾 8ms cos² 淡入淡出(去爆豆,快攻击听感不变)→ 峰值归一 -4dBFS。""" sig = sig - np.mean(sig) 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 sig[-nf:] *= fade[::-1] sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR) rng = np.random.default_rng(20260901) # ---- pulse:声波脉冲(0.15s,扫频 + 噪声瞬态 + 中频突出层) ---- d = 0.15 x = t(d) f = 60 + (180 - 60) * np.exp(-x * 22) # 180->60Hz 下扫 sweep = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 20) transient = bandpass(rng.standard_normal(len(x)), 900, 2400) * np.exp(-x * 55) # 1-2kHz 中频瞬态 save("pulse", 0.45 * sweep + 0.8 * transient) # ---- 材质尾音(0.2-0.25s) ---- # 闷石敲:低频短促敲击 d = 0.22 x = t(d) save("mat_stone", 0.9 * np.sin(2 * np.pi * 95 * x) * np.exp(-x * 40) + 0.3 * lowpass(rng.standard_normal(len(x)), 40) * np.exp(-x * 70)) # 空心木咚:两个衰减正弦 + 木腔共鸣 d = 0.25 x = t(d) save("mat_door", np.sin(2 * np.pi * 150 * x) * np.exp(-x * 26) + 0.5 * np.sin(2 * np.pi * 230 * x) * np.exp(-x * 38)) # 金属当啷:非谐波金属泛音簇 d = 0.24 x = t(d) save("mat_stair", sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc) for f, a, dc in [(1720, 1.0, 16), (2610, 0.6, 22), (3480, 0.4, 28), (860, 0.5, 12)])) # 水滴湿滑:短促水滴(下滑正弦)+ 高频飞溅 d = 0.22 x = t(d) f = 900 - 500 * x / d drop = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 34) splash = bandpass(rng.standard_normal(len(x)), 2500, 7000) * np.exp(-x * 60) * 0.35 save("mat_water", drop + splash) # ---- 侧向回声(mono 源 -> ffmpeg pan 派生预 pan 立体声) ---- # side_wall 闷哒:低频闷冲击(能量集中在 150-600Hz) d = 0.3 x = t(d) save("side_wall", lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 30) + 0.6 * np.sin(2 * np.pi * 130 * x) * np.exp(-x * 36)) # side_open 空旷回灵:中频拍点 + 两记渐弱反射尾(空间感) y = np.zeros(int(SR * 0.7), dtype=np.float32) for start, amp, dc in [(0.0, 1.0, 24), (0.13, 0.55, 18), (0.3, 0.3, 13)]: n = len(y) - int(SR * start) xx = np.arange(n) / SR tap = bandpass(np.random.default_rng(int(start * 100) + 5).standard_normal(n), 300, 2600) * np.exp(-xx * dc) * amp y[int(SR * start):] += tap save("side_open", y) # ---- 移动(脚步=低频 100-300Hz) ---- # step_own 单步 d = 0.22 x = t(d) save("step_own", lowpass(rng.standard_normal(len(x)), 70) * np.exp(-x * 38) + 0.4 * np.sin(2 * np.pi * 80 * x) * np.exp(-x * 45)) # step_turn 拖步转身:两段错位的拖擦(慢攻击) d = 0.5 x = t(d) drag1 = lowpass(rng.standard_normal(len(x)), 90) * env_exp(d, 0.05, 0.12) shift = int(0.22 * SR) drag2 = np.roll(drag1, shift) * 0.7 save("step_turn", 0.9 * drag1 + 0.9 * drag2) # bump 撞墙闷响:60-110Hz 重击 + 闷噪声 d = 0.35 x = t(d) save("bump", np.sin(2 * np.pi * 68 * x) * np.exp(-x * 18) + 0.5 * lowpass(rng.standard_normal(len(x)), 55) * np.exp(-x * 28)) # ---- 拾取 ---- # coin 金币叮当:非谐波高频泛音簇,两次碰撞(rpg coin 同族、参数区分) y = np.zeros(int(SR * 0.45), dtype=np.float32) for start, dec in [(0.0, 26), (0.11, 34)]: n = len(y) - int(SR * start) seg = np.arange(n) / SR sig = sum(a * np.sin(2 * np.pi * f * seg) * np.exp(-seg * dec) * 0.3 for f, a in [(4700, 1.0), (6300, 0.7), (8300, 0.45), (10500, 0.28)]) y[int(SR * start):] += sig save("coin", y) # potion 喝药咕咚:三声下滑「咕」+ 气泡噪声 y = np.zeros(int(SR * 0.8), dtype=np.float32) for i, start in enumerate([0.0, 0.18, 0.38]): d_ = 0.16 xx = t(d_) f0 = 340 - i * 55 f = f0 - 130 * xx / d_ g = np.sin(2 * np.pi * np.cumsum(f) / SR) * env_exp(d_, 0.01, 0.05) bub = bandpass(rng.standard_normal(len(xx)), 500, 1600) * np.exp(-xx * 30) * 0.25 y[int(SR * start):int(SR * start) + len(g)] += (g + bub) * (1 - i * 0.15) save("potion", y) # gear 换装金属轻响:短促金属扣合(低强度) d = 0.28 x = t(d) save("gear", 0.7 * sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc) for f, a, dc in [(1180, 1.0, 20), (1990, 0.5, 30), (520, 0.7, 14)])) # ---- 战斗 ---- # atk_swing 挥拳风声:带通噪声,幅度先涨后落(频率感觉下坠) d = 0.32 x = t(d) env = np.sin(np.pi * np.minimum(x / d, 1.0)) ** 1.5 swoosh = bandpass(rng.standard_normal(len(x)), 400, 3000) * env save("atk_swing", swoosh) # parry 格挡亮叮:高频金属亮铃(快攻击、余振) d = 0.45 x = t(d) save("parry", sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc) for f, a, dc in [(2500, 1.0, 9), (3750, 0.55, 13), (5300, 0.35, 17)])) # hurt 受击闷击:低频闷击 + 平滑噪声团(与 rpg hurt 同族、参数区分) d = 0.32 x = t(d) save("hurt", np.sin(2 * np.pi * 55 * x) * np.exp(-x * 16) + 0.55 * lowpass(rng.standard_normal(len(x)), 50) * np.exp(-x * 22)) # ---- levelup:上行钟声琶音(内容音,允许;D 小调五声 D4 F4 G4 A4 C5 D5) ---- y = np.zeros(int(SR * 1.8), dtype=np.float32) for i, f in enumerate([293.66, 349.23, 392.00, 440.00, 523.25, 587.33]): seg = np.arange(int(SR * 1.0)) / SR b = sum(a * np.sin(2 * np.pi * f * r * seg) * np.exp(-seg * dcy) for r, a, dcy in [(1, 1.0, 4), (2.4, 0.35, 7), (3.9, 0.15, 10)]) s = int(SR * i * 0.14) n = min(len(b), len(y) - s) y[s:s + n] += b[:n] * 0.4 save("levelup", y) # ---- turn_tick 战斗回合确认音(R2 D1-6:回合 15s 无操作的合成回执; # standard 档没有 gd_turn 语音,纯震感像死机——R1 live P2) ---- # 与 guide nudge 同族(880Hz 木鱼质感),0.16s 快衰减 + 高频点缀,可与其他 # 回合音叠放不糊(短、中高频、快衰减)。 d = 0.16 x = t(d) save("turn_tick", np.sin(2 * np.pi * 880 * x) * np.exp(-x * 38) + 0.35 * np.sin(2 * np.pi * 1760 * x) * np.exp(-x * 52)) def save_loop(name, sig): """loop 源存(amb_cave 专用):去直流 → 环回交叉淡化(尾 50ms 与头 50ms 加权拼接,接缝连续无爆音)→ **不做首尾淡化**(淡化会让循环每圈塌一次, 规格:去直流不淡化)→ 峰值归一 -4dBFS(同 one-shot 余量口径)。""" sig = sig - np.mean(sig) k = int(SR * 0.05) ramp = np.linspace(0.0, 1.0, k) head, tail = sig[:k].copy(), sig[-k:].copy() sig = sig[:-k] sig[:k] = head * ramp + tail * (1 - ramp) sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR) # ---- amb_cave 洞穴环境垫(R2 D0-3② 探索呼吸层:静默 >20s 播一段,10s 无缝循环源) ---- # 洞穴空气声=45-320Hz 带通噪(带通天然零直流、无次声漂移——褐噪 cumsum 的能量 # 全在 <30Hz,过编码 highpass 后只剩 -30dB 空气,R2 首版教训)×慢呼吸 LFO # (4 整周期/10s,接缝连续)+ 两粒远离接缝的远处水滴。真峰值/直流口径与 # one-shot 一致(解码回读 ≤-3dBFS、|直流|≤0.001)。 _n = int(SR * 10.0) _air = bandpass(rng.standard_normal(_n), 45, 320) _lfo = 0.7 + 0.3 * np.sin(2 * np.pi * 4 * np.arange(_n) / _n) _y = np.zeros(_n) for _start, _f0 in [(2.3, 620.0), (6.7, 540.0)]: _xx = np.arange(int(SR * 0.3)) / SR _f = _f0 - 300 * _xx / 0.3 _drop = np.sin(2 * np.pi * np.cumsum(_f) / SR) * np.exp(-_xx * 26) * 0.16 _s0 = int(SR * _start) _y[_s0:_s0 + len(_drop)] += _drop save_loop("amb_cave", _air * _lfo * 0.9 + _y) def pan_derive(name): """mono 源 -> 预 pan 立体声(能量只在 L 或 R 声道),ffmpeg pan 派生; 未指定的输出声道默认静音,即 pan=stereo|c0=c0(仅左)/ pan=stereo|c1=c0(仅右)。""" src = os.path.join(STAGING, name + ".wav") for side, out_ch in [("l", "c0"), ("r", "c1")]: out = os.path.join(STAGING, f"{name}_{side}.wav") subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src, "-af", f"pan=stereo|{out_ch}=c0", "-c:a", "pcm_f32le", out], check=True) for _n in ["side_wall", "side_open"]: pan_derive(_n) def encode(name, loop=False): """staging wav -> ogg q4 + m4a 128k,解码回读校验: 逐声道真峰值 <= -3dBFS(硬 pan 立体声不能按声道均值漏检)、|直流| <= 0.001(与在库资产基线一致)。 工艺:one-shot 源前后垫 60/120ms 静音(编码器软着陆,边界帧伪影落在垫音里); loop 源(amb_cave)**不垫不淡**(垫音/淡入出会破坏无缝循环),仅 highpass=f=30 (抑制 MDCT/AAC 解码直流再生,可听频段无影响);解码回读超限时按实测值迭代补偿 (真峰值补 volume,直流在源上预减实测偏移;均确定性,最多 4 轮)。""" src = os.path.join(STAGING, name + ".wav") base, _sr0 = sf.read(src, always_2d=True) if not loop: pad_h = np.zeros((int(SR * 0.06), base.shape[1]), dtype=np.float64) pad_t = np.zeros((int(SR * 0.12), base.shape[1]), dtype=np.float64) base = np.concatenate([pad_h, base, pad_t]) 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}") vol, dc_adj = 0.0, 0.0 tmp = "/tmp/_sfx_enc.wav" for _attempt in range(4): sig = base - dc_adj if vol < 0: sig = sig * (10 ** (vol / 20)) sf.write(tmp, sig.astype(np.float32), SR) subprocess.run([FFMPEG, "-y", "-v", "error", "-i", tmp, "-af", "highpass=f=30", "-ar", "44100", *cmd, out], check=True) chk = "/tmp/_sfx_qc.wav" subprocess.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True) y, _sr = sf.read(chk, always_2d=True) if loop: y = y[int(SR * 0.02):-int(SR * 0.02)] # QC 掐头去尾(编码器边界帧不计峰) m = y.mean(axis=1) tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9) # 逐声道峰值(硬 pan 不被均值稀释) dcv = float(np.mean(m)) ok_tp, ok_dc = tp <= -3.0, abs(dcv) <= 0.001 if ok_tp and ok_dc: break if not ok_tp: vol -= (tp + 3.0) + 0.25 # 超限差值 + 0.25dB 余量 if not ok_dc: dc_adj -= dcv # 源上预减解码实测直流 else: raise AssertionError(f"{name}.{ext}: 补偿后仍超限 tp={tp:.2f}dBFS dc={dcv:.4f}") extra = "" if loop: # 循环接缝量化:解码回读首尾样本差不超源峰的 5%(听感无咔哒) joint = float(np.max(np.abs(y[0] - y[-1]))) joint_db = 20 * np.log10(joint + 1e-9) - 20 * np.log10(np.max(np.abs(y)) + 1e-9) extra = f" joint={joint_db:+.1f}dB rel peak" print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS dc={dcv:+.4f} ch={y.shape[1]}{extra}") FILES = ["pulse", "mat_stone", "mat_door", "mat_stair", "mat_water", "side_wall_l", "side_wall_r", "side_open_l", "side_open_r", "step_own", "step_turn", "bump", "coin", "potion", "gear", "atk_swing", "parry", "hurt", "levelup", "turn_tick"] for _n in FILES: encode(_n) encode("amb_cave", loop=True) # amb_cave 循环规格专项:源级去直流(|均值|<=1e-4)且无首尾淡化(首尾样本非零) _amb, _ = sf.read(os.path.join(STAGING, "amb_cave.wav"), always_2d=True) assert abs(float(_amb.mean())) <= 1e-4, "amb_cave: 源直流超标" assert np.max(np.abs(_amb[0])) > 1e-3 and np.max(np.abs(_amb[-1])) > 1e-3, "amb_cave: 首尾被淡化(loop 规格违例)" # side_* 极性抽检:能量只在声像侧声道(解码回读) for _n, _side in [("side_wall_l", 0), ("side_wall_r", 1), ("side_open_l", 0), ("side_open_r", 1)]: y, _ = sf.read(os.path.join(STAGING, _n + ".wav"), always_2d=True) other = 1 - _side assert np.max(np.abs(y[:, other])) < 1e-6, f"{_n}: 非目标声道有能量" assert np.max(np.abs(y[:, _side])) > 0.3, f"{_n}: 目标声道无能量" print("dungeon SYN sfx done:", len(FILES), "files x2 formats")