- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决 - 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏 - 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言 - 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步 - 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿 - 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描) - 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目 验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
352 lines
13 KiB
Python
352 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""《围猎》程序合成音效(23-hunt.md §10.2,13 件,SYN 全部程序合成兜底)。
|
||
|
||
落盘:staging wav → tools/gen_hunt_syn_wav/<id>.wav(44.1kHz 立体声);
|
||
成品 → web/hunt/audio/sfx/<id>.{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())
|