#!/usr/bin/env python3 # -*- coding: utf-8 -*- """《雨夜专案》SYN 程序合成音效(批次 2,18-detective.md §8.2 标注 SYN 的条目)。 6 个 id(其余 6 条为 Stable Audio 3 条目,见 gen_sfx_sa3.py): sig_shun 小顺签名:布掸子拍柜台两下(双拍软拍,间隔 0.16s) sig_wen 温太太签名:门环叩两下(金属环叩木门,二连击) sig_fore 法医签名:纸页翻动 + 钢笔划线(噪声簌 + 笔尖刮擦两段) contra_hit 指证命中:不和谐金属小二度相击 + 盖章尾(全游戏最强确认音,-4dBFS) contra_miss 指证落空:钝木鱼一下(非 error 音色、不威吓,-8dBFS) turn_tick 看门狗 L1:880Hz 木鱼族轻响(dungeon 同配方自存一份,-8dBFS) 签名 earcon 规格:条目切换/指证选择/状态复述时前置(0.3-0.5s 量级),节拍可数 (两下=可辨身份),电平中低不抢人声。 可复现:本脚本入库,一键出 web/detective/audio/sfx/*.{ogg,m4a};中间 wav 在 gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k; one-shot 真峰值 <= -3dBFS(解码回读法)。 用法:.venv-tts/bin/python web/detective/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" 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=-6.0): """mono 存。工艺同 tools/fix_clicks.py:去直流 → 首尾 8ms cos² 淡入淡出 → 峰值归一指定 dBFS(签名 earcon 默认 -6,contra_hit -4,miss/tick -8)。""" 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) # ---- sig_shun:布掸子拍柜台两下(软布拍击:中频噪声团 + 轻微柜台木腔共鸣) ---- def duster_pat(dur=0.10): x = t(dur) pat = bandpass(rng.standard_normal(len(x)), 250, 1600) * env_exp(dur, 0.012, 0.05) wood = 0.4 * np.sin(2 * np.pi * 190 * x) * np.exp(-x * 55) return pat + wood y = np.zeros(int(SR * 0.42), dtype=np.float64) for i, amp in enumerate([1.0, 0.85]): k = duster_pat() s = int(SR * (0.02 + i * 0.16)) y[s:s + len(k)] += k * amp save("sig_shun", y) # ---- sig_wen:门环叩两下(金属环叩木门:非谐波金属簇 + 门板低频响应,二连击) ---- def knocker(dur=0.13): x = t(dur) ring = sum(a * np.sin(2 * np.pi * fr * x) * np.exp(-x * dc) for fr, a, dc in [(880, 1.0, 30), (1350, 0.5, 42), (2210, 0.3, 55)]) door = 0.55 * np.sin(2 * np.pi * 120 * x) * np.exp(-x * 40) thud = lowpass(rng.standard_normal(len(x)), 80) * np.exp(-x * 70) * 0.45 return ring + door + thud y = np.zeros(int(SR * 0.46), dtype=np.float64) for i, amp in enumerate([1.0, 0.8]): k = knocker() s = int(SR * (0.02 + i * 0.18)) y[s:s + len(k)] += k * amp save("sig_wen", y) # ---- sig_fore:纸页翻动 + 钢笔划线(簌簌噪声扫频 + 笔尖刮擦两划) ---- d = 0.30 x = t(d) page = bandpass(rng.standard_normal(len(x)), 900, 5000) * env_exp(d, 0.02, 0.10) * 0.8 # 翻页簌 y = page.copy() for start, dur2 in [(0.14, 0.10), (0.21, 0.07)]: # 钢笔两划 seg = t(dur2) pen = bandpass(np.random.default_rng(int(start * 1000)).standard_normal(len(seg)), 1500, 4500) * env_exp(dur2, 0.01, 0.03) * 0.5 s = int(SR * start) y[s:s + len(pen)] += pen save("sig_fore", y) # ---- contra_hit:不和谐金属小二度相击 + 盖章尾(矛盾证实音,全游戏最强确认音) ---- # 1250/1320Hz 小二度拍频互殴 0.18s -> 顿 0.05s -> 低频盖章闷击(thunk)收尾。 d1 = 0.18 x1 = t(d1) clash = (np.sin(2 * np.pi * 1250 * x1) + np.sin(2 * np.pi * 1320 * x1) + 0.4 * np.sin(2 * np.pi * 2500 * x1) + 0.3 * np.sin(2 * np.pi * 2640 * x1)) * np.exp(-x1 * 13) d2 = 0.16 x2 = t(d2) stamp = (np.sin(2 * np.pi * 95 * x2) * np.exp(-x2 * 22) + 0.6 * lowpass(rng.standard_normal(len(x2)), 70) * np.exp(-x2 * 40)) save("contra_hit", np.concatenate([clash, np.zeros(int(SR * 0.05)), stamp, np.zeros(int(SR * 0.08))]), peak_db=-4.0) # ---- contra_miss:钝木鱼一下(低频短木腔音,非 error 音色) ---- d = 0.16 x = t(d) save("contra_miss", np.sin(2 * np.pi * 310 * x) * np.exp(-x * 30) + 0.5 * np.sin(2 * np.pi * 620 * x) * np.exp(-x * 45) + 0.3 * lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 70), peak_db=-8.0) # ---- turn_tick:880Hz 木鱼族轻响(dungeon 同配方自存一份;无进展轨 L1 不占台词通道) ---- 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), peak_db=-8.0) def encode(name): """staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。 工艺:源尾部垫 50ms 静音(编码器软着陆)+ highpass=f=30(抑制解码直流再生); 纯弦 AAC 过冲按实测迭代补 volume(abyss link_beep 同坑,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_det.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_det.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 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 ["sig_shun", "sig_wen", "sig_fore", "contra_hit", "contra_miss", "turn_tick"]: encode(_n) print("detective SYN sfx done: 6 files x2 formats")