#!/usr/bin/env python3 # -*- coding: utf-8 -*- """《深海回音》SYN 程序合成音效(批次 2,17-abyss.md §5.2 标注 SYN 的条目)。 6 个 id(其余 12 条为 Stable Audio 3 条目,见 gen_sfx_sa3.py): sonar_ping 你发出的声呐脉冲:1.4->0.9kHz 正弦 chirp 220ms 指数衰减,-6dBFS (设计指定电平;「回波家族统一音头」——搜索等待单击恒出此音) pipe_knock 管路敲击暗号(老白 SOS):三短三长三短固定节拍金属敲击 (节拍精确可复现、断言可数;短 0.12s / 长 0.36s / 间隔 0.12s) link_beep 电台接通:双音上行 0.3s ascent_chime 出水确认:上行三音琶音 0.4s ui_confirm 菜单确认(全局先例:880->1320Hz 双音) ui_menu 菜单呼出(全局先例:330+495Hz 软音) 可复现:本脚本入库,一键出 web/abyss/audio/sfx/*.{ogg,m4a};中间 wav 在 gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k; one-shot 真峰值 <= -3dBFS(解码回读法,源归一留 MDCT 过冲余量);无 loop 类条目 (amb_* 三条走 SA3「amb 床只去直流」工艺,在 gen_sfx_sa3.py)。 用法:.venv-tts/bin/python web/abyss/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): kh, kl = max(1, int(SR / (np.pi * hi))), max(1, int(SR / (np.pi * lo))) return lowpass(x, kh) - lowpass(x, kl) def save(name, sig, peak_db=-4.0): """mono 存。工艺同 tools/fix_clicks.py:去直流 → 首尾 8ms cos² 淡入淡出 → 峰值归一指定 dBFS(sonar_ping 按设计 -6dBFS)。""" 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) * 10 ** (peak_db / 20) sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR) rng = np.random.default_rng(20260909) # ---- sonar_ping:声呐脉冲(1.4->0.9kHz 正弦 chirp 220ms 指数衰减,-6dBFS) ---- d = 0.22 x = t(d) f = 1400 - (1400 - 900) * x / d # 1.4 -> 0.9kHz 线性下滑 chirp = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 14) click = bandpass(rng.standard_normal(len(x)), 2000, 6000) * np.exp(-x * 60) * 0.2 # 起振细瞬态 save("sonar_ping", 0.92 * chirp + click, peak_db=-6.0) # ---- pipe_knock:管路敲击暗号(三短三长三短,SOS 节拍精确可复现/可数) ---- # 单击=金属管体振(非谐波簇 640/1490/2330Hz,短促衰减),长短仅持续不同。 def knock(dur, decay=26.0): x = t(dur) hit = sum(a * np.sin(2 * np.pi * fr * x) * np.exp(-x * decay * (1 + fr / 3000)) for fr, a in [(640, 1.0), (1490, 0.55), (2330, 0.3), (410, 0.5)]) thud = lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 90) * 0.5 return (hit + thud) * env_exp(dur, 0.002, 0.01) D_SHORT, D_LONG, GAP = 0.12, 0.36, 0.12 pattern = ([D_SHORT] * 3 + [D_LONG] * 3 + [D_SHORT] * 3) # 三短三长三短 _y = np.zeros(int(SR * (sum(pattern) + GAP * (len(pattern) - 1) + 0.15)), dtype=np.float64) _pos = 0.15 / 2 * SR for dur in pattern: k = knock(dur) _y[int(_pos):int(_pos) + len(k)] += k _pos += int((dur + GAP) * SR) save("pipe_knock", _y) # ---- link_beep:电台接通双音上行 0.3s(620->930Hz,通讯族音色,与 ui 族区分) ---- d = 0.30 a = np.sin(2 * np.pi * 620 * t(0.12)) * env_exp(0.12, 0.004, 0.02) b = np.sin(2 * np.pi * 930 * t(0.16)) * env_exp(0.16, 0.004, 0.06) save("link_beep", np.concatenate([a, np.zeros(int(SR * 0.02)), b])) # ---- ascent_chime:出水确认上行三音琶音 0.4s(C5-E5-G5,干净上行=「上浮完成」) ---- y = np.zeros(int(SR * 0.40), dtype=np.float64) for i, fr in enumerate([523.25, 659.25, 783.99]): seg = t(0.22) tone = (np.sin(2 * np.pi * fr * seg) + 0.4 * np.sin(2 * np.pi * fr * 2 * seg)) * np.exp(-seg * 10) s = int(SR * i * 0.09) n = min(len(tone), len(y) - s) y[s:s + n] += tone[:n] * 0.6 save("ascent_chime", y) # ---- ui_confirm / ui_menu:全局先例同款(storyline/radio gen_sfx.py) ---- a = np.sin(2 * np.pi * 880 * t(0.09)) * env_exp(0.09, 0.005, 0.003) b = np.sin(2 * np.pi * 1320 * t(0.14)) * env_exp(0.14, 0.005, 0.012) save("ui_confirm", np.concatenate([a, np.zeros(int(SR * 0.02)), b, np.zeros(int(SR * 0.05))])) 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))])) def encode(name): """staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。 工艺:源尾部垫 50ms 静音(编码器软着陆)+ highpass=f=30(抑制 MDCT/AAC 解码直流再生); 纯弦 AAC 过冲按实测迭代补 volume(link_beep.m4a 首轮实测 -2.82dBFS 超限,dungeon 补偿同款)。""" src = os.path.join(STAGING, name + ".wav") base, _sr0 = sf.read(src, always_2d=True) pad_t = np.zeros((int(SR * 0.05), base.shape[1]), dtype=np.float64) base = np.concatenate([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_abyss.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_abyss.wav" subprocess.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) dcv = float(np.mean(y.mean(axis=1))) if tp <= -3.0 and abs(dcv) <= 0.001: break if tp > -3.0: vol -= (tp + 3.0) + 0.25 # 超限差值 + 0.25dB 余量 if abs(dcv) > 0.001: dc_adj -= dcv else: raise AssertionError(f"{name}.{ext}: 补偿后仍超限 tp={tp:.2f}dBFS dc={dcv:.4f}") print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS" + (f" (vol {vol:+.2f}dB)" if vol else "")) for _n in ["sonar_ping", "pipe_knock", "link_beep", "ascent_chime", "ui_confirm", "ui_menu"]: encode(_n) print("abyss SYN sfx done: 6 files x2 formats")