#!/usr/bin/env python3 # -*- coding: utf-8 -*- """《围猎》程序合成音效(23-hunt.md §10.2,13 件,SYN 全部程序合成兜底)。 落盘:staging wav → tools/gen_hunt_syn_wav/.wav(44.1kHz 立体声); 成品 → web/hunt/audio/sfx/.{ogg,m4a}(tools/bin/ffmpeg 双格式)。 pan 由运行时引擎 opts.pan / 游戏层 panner 承担,资产本体为双声道同信号(gen_rpg_syn 先例)。 one-shot QC 标准(11 件):去直流 + 首尾 8ms cos² 淡化 + 编码后解码回读 真峰值 ≤ -3dBFS(MDCT 过冲坑:以 ebur128 peak=true 实测为准,超限自动降档重编)。 loop QC 标准(2 件,4s 无缝循环):只去直流(首尾禁淡化,事件网格周期化 + 尾部 回卷 overlap-add 保证循环点连续),另做真峰值校验只报告不强制。 频带分离(§8 R8):loop 低频 100-300Hz / howl 中高 1-3kHz / 打击音宽带动感。 时长口径(§10.2/R5):非语音信息全部 <1s——号角 0.9s、哨音 0.6s、chime 0.4s、 击杀 0.5-0.7s;loop 源 4.0s。 用法:.venv-tts/bin/python tools/gen_hunt_syn.py """ import os import subprocess import sys import numpy as np import soundfile as sf ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg") OUT = os.path.join(ROOT, "tools", "gen_hunt_syn_wav") DST = os.path.join(ROOT, "web", "hunt", "audio", "sfx") os.makedirs(OUT, exist_ok=True) os.makedirs(DST, exist_ok=True) sr = 44100 ONE_SHOT_TP_CEIL = -3.0 # dBFS,one-shot 真峰值上限 SAMPLE_PEAK = 0.66 # ≈ -3.6dBFS 采样峰值,给 MDCT 过冲留余量 FADE_N = int(sr * 0.008) # 8ms cos² _rng = np.random.default_rng(23) def dc(y): return y - np.mean(y) def fade(y): w = np.sin(np.linspace(0, np.pi / 2, FADE_N)) ** 2 # cos² 等价形态 y = y.copy() y[:FADE_N] *= w y[-FADE_N:] *= w[::-1] return y def norm(y, peak=SAMPLE_PEAK): m = np.max(np.abs(y)) return (y / m * peak) if m > 0 else y def stereo(y): return np.stack([y, y], axis=1).astype(np.float32) def place(y, start_s, sig): s = int(start_s * sr) n = min(len(sig), len(y) - s) y[s:s + n] += sig[:n] def bp_noise(n, lo, hi, order=4): """简易零相位带通噪声(FFT 矩形窗,合成音效用足够)。""" x = _rng.standard_normal(n) X = np.fft.rfft(x) freqs = np.fft.rfftfreq(n, 1 / sr) mask = (freqs >= lo) & (freqs <= hi) # 软边界,降噼啪 soft = np.clip(mask.astype(float), 0, 1) y = np.fft.irfft(X * soft, n) return y / (np.max(np.abs(y)) + 1e-9) def sine_track(n, f0, f1, amp=1.0, vib_hz=0.0, vib_depth=0.0): """线性滑音正弦(可选颤音),相位连续。""" t = np.arange(n) / sr f = np.linspace(f0, f1, n) if vib_hz: f = f * (1 + vib_depth * np.sin(2 * np.pi * vib_hz * t)) ph = 2 * np.pi * np.cumsum(f) / sr return amp * np.sin(ph) def harm_stack(n, f0, f1, harms, vib_hz=0.0, vib_depth=0.0): """谐波簇滑音:harms=[(ratio, gain), ...]。""" y = np.zeros(n) for r, g in harms: y += g * sine_track(n, f0 * r, f1 * r, 1.0, vib_hz, vib_depth) return y # ---------------------------------------------------------------- one-shot 渲染 def r_atk_whoosh(): # 挥击风声 0.3s:下扫带通噪声,快起快落 n = int(sr * 0.30) t = np.arange(n) / sr y = bp_noise(n, 400, 2600) sweep = np.exp(-((t - 0.10) / 0.11) ** 2) # 10ms 处峰顶的粗包络 env = (1 - np.exp(-t * 90)) * np.exp(-t * 14) return dc(y * env * (0.6 + 0.4 * sweep)) def r_atk_swish(): # 挥空空荡风声 0.3s:更高更空、无低频着落(归因真话:没打到) n = int(sr * 0.30) t = np.arange(n) / sr y = bp_noise(n, 900, 3400) env = (1 - np.exp(-t * 70)) * np.exp(-t * 11) hollow = 1 + 0.35 * np.sin(2 * np.pi * 7 * t) # 轻微空洞感 AM return dc(y * env * hollow * 0.8) def r_kill_wolf(): # 狼哀嚎倒地 0.5s:短促下滑哀嚎 + 着地闷响 n = int(sr * 0.50) t = np.arange(n) / sr yelp = harm_stack(n, 1150, 520, [(1, 1.0), (2, 0.35), (3, 0.12)], vib_hz=9, vib_depth=0.03) yelp *= (1 - np.exp(-t * 120)) * np.exp(-t * 9) thud = np.sin(2 * np.pi * 70 * t) * np.exp(-t * 30) thud += bp_noise(n, 80, 300) * np.exp(-t * 40) * 0.5 return dc(yelp * 0.8 + np.roll(thud, int(0.30 * sr)) * 0.9) def r_kill_bear(): # 熊闷重倒地 0.7s:低吼爆 + 重落地 n = int(sr * 0.70) t = np.arange(n) / sr growl = harm_stack(n, 130, 70, [(1, 1.0), (2, 0.5), (3, 0.2)], vib_hz=22, vib_depth=0.10) growl *= (1 - np.exp(-t * 60)) * np.exp(-t * 6) thud = np.sin(2 * np.pi * 52 * t) * np.exp(-t * 16) thud += bp_noise(n, 60, 220) * np.exp(-t * 20) * 0.7 return dc(growl * 0.5 + np.roll(thud, int(0.32 * sr))) def r_skill_spin(): # 旋风斩气旋 0.8s(中央,大动态):加速旋转呼啸 + 顶部爆点 n = int(sr * 0.80) t = np.arange(n) / sr y = bp_noise(n, 300, 3000) rate = 3 + 14 * (t / t[-1]) ** 2 # 旋转 AM 加速 3→17Hz am = 0.5 + 0.5 * np.sin(2 * np.pi * np.cumsum(rate) / sr) env = (1 - np.exp(-t * 25)) * np.exp(-np.maximum(t - 0.55, 0) * 9) top = sine_track(n, 900, 1800, 0.25) * np.exp(-np.abs(t - 0.55) * 18) # 气旋顶部哨 return dc(y * am * env * 1.1 + top) def r_breach_thud(): # 营地被撞闷响 + 木栅裂 0.6s(中央) n = int(sr * 0.60) t = np.arange(n) / sr thud = np.sin(2 * np.pi * 55 * t) * np.exp(-t * 13) thud += bp_noise(n, 70, 260) * np.exp(-t * 18) * 0.8 # 木栅裂:3 串高频脆响 crack = np.zeros(n) for i, st in enumerate([0.05, 0.14, 0.26]): seg_n = n - int(st * sr) seg_t = np.arange(seg_n) / sr c = bp_noise(seg_n, 1400, 4200) * np.exp(-seg_t * (60 - i * 10)) * (0.8 - i * 0.2) place(crack, st, c) return dc(thud + crack * 0.7) def r_horn_wave(): # 波次号角 0.9s(中央):铜管状谐波簇,起音膨胀尾音下沉 n = int(sr * 0.90) t = np.arange(n) / sr f0, f1 = 220, 208 # A3 微沉 horn = harm_stack(n, f0, f1, [(1, 1.0), (2, 0.55), (3, 0.30), (4, 0.14)], vib_hz=5.5, vib_depth=0.008) env = (1 - np.exp(-t * 9)) * np.exp(-np.maximum(t - 0.62, 0) * 8) breath = bp_noise(n, 300, 1200) * env * 0.06 return dc(horn * env + breath) def r_join_chime(): # 队友加入 0.4s:双音上行(E5→A5 钟声) n = int(sr * 0.40) y = np.zeros(n) for st, f in [(0.0, 659.25), (0.14, 880.0)]: seg_n = n - int(st * sr) seg_t = np.arange(seg_n) / sr bell = sum(g * np.sin(2 * np.pi * f * r * seg_t) * np.exp(-seg_t * d) for r, g, d in [(1, 1.0, 10), (2.76, 0.25, 16)]) place(y, st, bell * 0.6) return dc(y) def r_call_whistle(): # 求援哨音 0.6s(座 3 呼喊用):裁判哨式高频颤音 n = int(sr * 0.60) t = np.arange(n) / sr w = sine_track(n, 2350, 2450, 1.0, vib_hz=28, vib_depth=0.012) w += 0.2 * sine_track(n, 4700, 4900, 1.0, vib_hz=28, vib_depth=0.012) env = (1 - np.exp(-t * 200)) * np.exp(-np.maximum(t - 0.42, 0) * 12) return dc(w * env * 0.5) def r_howl_wolf(): # 生成呼啸 0.8s:起-扬-落狼嚎(1-3kHz 中高带) n = int(sr * 0.80) t = np.arange(n) / sr f = 620 + 520 * np.sin(np.pi * np.clip(t / 0.75, 0, 1)) ** 1.4 # 拱形音高 ph = 2 * np.pi * np.cumsum(f * (1 + 0.02 * np.sin(2 * np.pi * 6 * t))) / sr howl = np.sin(ph) + 0.3 * np.sin(2 * ph) env = (1 - np.exp(-t * 12)) * np.exp(-np.maximum(t - 0.5, 0) * 7) return dc(howl * env * 0.7) def r_howl_bear(): # 生成低吼 1.0s:低频粗糙吼声,慢 AM n = int(sr * 1.00) t = np.arange(n) / sr g = harm_stack(n, 95, 75, [(1, 1.0), (2, 0.6), (3, 0.3)], vib_hz=17, vib_depth=0.08) g += bp_noise(n, 150, 500) * 0.25 env = (1 - np.exp(-t * 10)) * np.exp(-np.maximum(t - 0.6, 0) * 6) return dc(g * env * 0.8) ONE_SHOTS = { "atk_whoosh": r_atk_whoosh, "atk_swish": r_atk_swish, "kill_wolf": r_kill_wolf, "kill_bear": r_kill_bear, "skill_spin": r_skill_spin, "breach_thud": r_breach_thud, "horn_wave": r_horn_wave, "join_chime": r_join_chime, "call_whistle": r_call_whistle, "howl_wolf": r_howl_wolf, "howl_bear": r_howl_bear, } # ---------------------------------------------------------------- loop 渲染(4s 无缝) def wrap_seamless(y, extra): """把超出 4s 的尾部回卷叠加到开头,保证循环点连续。""" n = int(sr * 4.0) if len(y) > n: tail = y[n:] y = y[:n] y[:len(tail)] += tail return y def r_loop_wolf(): # 狼碎步 + 低呜(100-300Hz 低频带;4 步/秒小跑,周期 1s 整除 4s) n = int(sr * 4.0) y = np.zeros(n) step = 0.25 # 4 步/秒,16 步整除 4s,尾 0.09s 不出界无需回卷 for i in range(16): L = int(sr * 0.09) tt = np.arange(L) / sr paw = bp_noise(L, 120, 300) * np.exp(-tt * 70) * (1 - np.exp(-tt * 400)) paw += np.sin(2 * np.pi * (95 + (i % 2) * 12) * tt) * np.exp(-tt * 60) * 0.5 place(y, i * step, paw * (0.55 + 0.15 * ((i * 7) % 3))) t = np.arange(n) / sr # 垫底低呜:恒定 200Hz(4s 整 800 周期)+ 0.25Hz AM(1 周期)+ 4.5Hz 颤音 # (18 整周期、积分归零)——所有成分循环点相位对齐,无接缝。 whim = sine_track(n, 200, 200, 0.10, vib_hz=4.5, vib_depth=0.05) y += whim * (0.5 + 0.5 * np.sin(2 * np.pi * 0.25 * t)) # 0.25Hz 整周期 y = wrap_seamless(y, None) return dc(y) def r_loop_bear(): # 熊重步 + 喘息(重低频;约 1.5 步/秒,周期 4/3 s 不整除→用 2s 网格 8 步) n = int(sr * 4.0) y = np.zeros(n) for i in range(8): # 每 0.5s 一步(左右交替),8 步整除 4s,尾 0.16s 不出界 L = int(sr * 0.16) tt = np.arange(L) / sr hoof = np.sin(2 * np.pi * (58 + (i % 2) * 7) * tt) * np.exp(-tt * 26) hoof += bp_noise(L, 70, 240) * np.exp(-tt * 30) * (1 - np.exp(-tt * 300)) * 0.8 place(y, i * 0.5, hoof) t = np.arange(n) / sr # 喘息:恒定 80Hz(4s 整 320 周期)+ 0.5Hz AM/颤音(2 整周期)——循环点相位对齐。 breath = sine_track(n, 80, 80, 0.12, vib_hz=0.5, vib_depth=0.06) y += breath * (0.4 + 0.6 * (0.5 + 0.5 * np.sin(2 * np.pi * 0.5 * t))) y = wrap_seamless(y, None) return dc(y) LOOPS = { "loop_wolf": r_loop_wolf, "loop_bear": r_loop_bear, } # ---------------------------------------------------------------- 编码 + 真峰值校验 def encode(wav_path, base): for args, ext in [(["-c:a", "libvorbis", "-q:a", "3"], ".ogg"), (["-c:a", "aac", "-b:a", "128k"], ".m4a")]: subprocess.run([FFMPEG, "-y", "-v", "error", "-i", wav_path, "-ar", "44100", *args, base + ext], check=True) def true_peak_db(path): r = subprocess.run([FFMPEG, "-i", path, "-af", "ebur128=peak=true", "-f", "null", "-"], capture_output=True, text=True) peaks = [float(line.split("Peak:")[1].strip().split()[0]) for line in r.stderr.splitlines() if "Peak:" in line and "dBFS" in line] return max(peaks) if peaks else float("nan") def check_tp(base): return max(true_peak_db(base + ".ogg"), true_peak_db(base + ".m4a")) def main(): report = [] problems = [] for fid, fn in ONE_SHOTS.items(): wav_path = os.path.join(OUT, fid + ".wav") base = os.path.join(DST, fid) y = fade(norm(dc(fn()))) sf.write(wav_path, stereo(y), sr) encode(wav_path, base) tp = check_tp(base) if tp > ONE_SHOT_TP_CEIL: # MDCT 过冲超限:按超出量降档重编一次再验 gain = 10 ** ((ONE_SHOT_TP_CEIL - tp) / 20) * 0.98 sf.write(wav_path, stereo(y * gain), sr) encode(wav_path, base) tp2 = check_tp(base) report.append((fid, len(y) / sr, tp, f"降 {20 * np.log10(gain):.1f}dB 重编 -> {tp2:.2f}")) tp = tp2 else: report.append((fid, len(y) / sr, tp, "")) if tp > ONE_SHOT_TP_CEIL: problems.append(f"{fid}: 真峰值 {tp:.2f} dBFS 仍超 {ONE_SHOT_TP_CEIL}") for fid, fn in LOOPS.items(): wav_path = os.path.join(OUT, fid + ".wav") base = os.path.join(DST, fid) y = norm(fn()) # loop:去直流已在渲染内;无首尾淡化(无缝优先) sf.write(wav_path, stereo(y), sr) # 无缝断言:循环点首尾采样差应足够小(回卷后连续) seam = abs(float(y[0]) - float(y[-1])) encode(wav_path, base) tp = check_tp(base) report.append((fid, len(y) / sr, tp, f"loop seam |y0-y1|={seam:.4f}")) if tp > ONE_SHOT_TP_CEIL: problems.append(f"{fid}: loop 真峰值 {tp:.2f} dBFS 超 {ONE_SHOT_TP_CEIL}(建议降档)") print(f"{'id':14s} {'dur(s)':>7s} {'TP(dBFS)':>9s} note") for fid, dur, tp, note in report: print(f"{fid:14s} {dur:7.2f} {tp:9.2f} {note}") if problems: print("PROBLEMS:") for p in problems: print(" ", p) return 1 print(f"ALL OK: {len(ONE_SHOTS)} one-shot + {len(LOOPS)} loop -> {DST}") return 0 if __name__ == "__main__": sys.exit(main())