Files
SoundGame/tools/gen_space_syn.py
T
xsl ddada6980c fix(space): 盲航刺耳音效重制 + 状态/反馈盲区修复(玩家代理取证迭代)
- F1 程序合成重制5件刺耳音效(alarm≥3kHz能量82%→0,全件≤-3.4dBFS)+新增挥空音
- F2 P0:撞击方位语音防顶断补播(保护期结束死亡窗 hit_* 零毫秒被杀根因)
- F3 保护期教学句7连互截去重;F4 新句 protect_soon 50s预告;F5 boost_not_ready 冷却拒绝回音;F6 激活补CONFIRM震;F7 whiff
- 验证:playtest 172/172 + guide 122 + 终检全绿 + 14游戏回归零回退 + live两轮复跑坐实
- 档案 research/space-polish/round1-2(取证+修复证据);04号文档追加R5修订记录
2026-09-14 20:16:58 +08:00

251 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""《盲航》程序合成音效重制(space polish R5,research/space-polish/round1 取证结论)。
重制 5 件 + 新增 1 件(落 web/space/audio/sfx/):
alarm 全程最高暴露(每颗陨石两遍)→ 低谐波电子双音,5kHz 以上近零能量
dodge 高频嘶声(-2.30dBFS / 63.6%≥3kHz)→ 中频气声扫频
shield_block 保护期每 4s 一响 + 真峰值超标 → 柔和电涟波
boost 真峰值 -2.70dBFS / 53%≥3kHz → 低频引擎冲刺
shield 真峰值 -2.90dBFS 超标 → 上升 shimmer 重制
whiff 新增:挥空/滑错方向的轻反馈(归因真话:无效手势有回音)
one-shot QC(同 gen_cipher_syn.py 标准):去直流 + 首尾 8ms cos² + 真峰值 ≤-3dBFS
(编码后 ebur128 实测,MDCT 过冲超限自动降档重编)。
用法:.venv-tts/bin/python tools/gen_space_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_space_syn_wav")
DST = os.path.join(ROOT, "web", "space", "audio", "sfx")
os.makedirs(OUT, exist_ok=True)
os.makedirs(DST, exist_ok=True)
sr = 44100
ONE_SHOT_TP_CEIL = -3.0
SAMPLE_PEAK = 0.66
FADE_N = int(sr * 0.008)
_rng = np.random.default_rng(20260914)
def dc(y):
return y - np.mean(y)
def fade(y):
w = np.sin(np.linspace(0, np.pi / 2, FADE_N)) ** 2
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 bp_noise(n, lo, hi):
x = _rng.standard_normal(n)
X = np.fft.rfft(x)
freqs = np.fft.rfftfreq(n, 1 / sr)
mask = ((freqs >= lo) & (freqs <= hi)).astype(float)
y = np.fft.irfft(X * mask, n)
return y / (np.max(np.abs(y)) + 1e-9)
def sweep_noise(n, f_lo, f_hi, bw=900):
"""质心随时间线性扫动的带通噪声(气声/扫频体感,无离散高频谐波)。"""
x = _rng.standard_normal(n)
X = np.fft.rfft(x)
freqs = np.fft.rfftfreq(n, 1 / sr)
t = np.arange(n) / sr
center = f_lo + (f_hi - f_lo) * np.minimum(t / (n / sr), 1.0)
# 时变带通:按块扫中心频率
y = np.zeros(n)
blk = int(sr * 0.02)
for s in range(0, n, blk):
e = min(n, s + blk)
c = center[s:e].mean()
m = ((freqs >= max(20, c - bw)) & (freqs <= c + bw)).astype(float)
seg = np.fft.irfft(X * m, n)
y[s:e] = seg[s:e]
m = np.max(np.abs(y))
return y / m if m > 0 else y
def tone(n, f, decay=0.0, harm=()):
t = np.arange(n) / sr
y = np.sin(2 * np.pi * f * t)
for h, g in harm:
y += g * np.sin(2 * np.pi * f * h * t)
if decay:
y *= np.exp(-t * decay)
return y
# ---------------------------------------------------------------- one-shot 渲染
def r_alarm(): # 1.0s 电子双音警报:700/560Hz 圆角短促 beep×2,谐波收敛 <2.5kHz
n = int(sr * 1.0)
y = np.zeros(n)
for st, f in [(0.05, 700), (0.42, 560)]:
L = int(sr * 0.20)
tt = np.arange(L) / sr
beep = np.sin(2 * np.pi * f * tt) + 0.22 * np.sin(2 * np.pi * f * 2 * tt) \
+ 0.06 * np.sin(2 * np.pi * f * 3 * tt)
beep *= (1 - np.exp(-tt * 250)) * np.exp(-np.maximum(tt - 0.12, 0) * 60)
beep += bp_noise(L, 300, 2200) * np.exp(-tt * 90) * 0.10 # 电子毛边,低带
y[int(st * sr):int(st * sr) + L] += beep
return dc(y)
def r_dodge(): # 1.2s 闪避成功:500→2100Hz 气声扫 + 一记软木鱼中频确认
n = int(sr * 1.2)
t = np.arange(n) / sr
y = sweep_noise(n, 500, 2100, bw=1000)
env = (1 - np.exp(-t * 120)) * np.exp(-np.maximum(t - 0.55, 0) * 7)
y = y * env * 0.8
L = int(sr * 0.16)
tt = np.arange(L) / sr
knock = np.sin(2 * np.pi * 740 * tt) * np.exp(-tt * 45)
knock += bp_noise(L, 400, 1800) * np.exp(-tt * 70) * 0.5
y[int(0.06 * sr):int(0.06 * sr) + L] += knock * 0.5
return dc(y)
def r_shield_block(): # 1.5s 护盾吸收:250-2300 电涟噪声衰减 + 150Hz 低频顿 + 弱 520Hz 余振
n = int(sr * 1.5)
t = np.arange(n) / sr
rip = bp_noise(n, 250, 2300) * np.exp(-t * 4.5)
rip *= 1 + 0.5 * np.sin(2 * np.pi * 11 * t) * np.exp(-t * 3) # 涟波调制
th = np.sin(2 * np.pi * 150 * t) * np.exp(-t * 18) * 0.6
ring = np.sin(2 * np.pi * 520 * t) * np.exp(-t * 10) * 0.18
y = (rip * 0.7 + th + ring) * (1 - np.exp(-t * 300))
return dc(y)
def r_boost(): # 1.8s 引擎冲刺:55-90Hz 低哼 + 150-850Hz 推进噪声渐强,暗频谱
n = int(sr * 1.8)
t = np.arange(n) / sr
rum = np.sin(2 * np.pi * 62 * t) + 0.5 * np.sin(2 * np.pi * 93 * t)
rum *= (1 - np.exp(-t * 30)) * np.exp(-np.maximum(t - 1.2, 0) * 4)
thrust = sweep_noise(n, 180, 850, bw=500)
env = np.minimum(1, t / 0.5) * np.exp(-np.maximum(t - 1.1, 0) * 5)
growl = bp_noise(n, 100, 500) * 0.5 * env
y = rum * 0.55 + (thrust * 0.55 + growl) * env
return dc(y)
def r_shield(): # 1.5s 护盾展开:320→980Hz 正弦爬升 + 轻 shimmer 噪声,谐波 <4kHz
n = int(sr * 1.5)
t = np.arange(n) / sr
f0 = 320 + (980 - 320) * np.minimum(t / 1.0, 1.0) ** 1.4
ph = 2 * np.pi * np.cumsum(f0) / sr
y = np.sin(ph) * 0.7 + 0.15 * np.sin(2 * ph)
shim = bp_noise(n, 900, 3600) * 0.10 * np.minimum(1, t / 0.3)
env = (1 - np.exp(-t * 60)) * np.exp(-np.maximum(t - 1.05, 0) * 8)
return dc((y + shim) * env)
def r_whiff(): # 0.4s 挥空轻反馈:150-900Hz 低带噪声叹 + 200Hz 软顿(设计音量低,game 层 0.55)
n = int(sr * 0.4)
t = np.arange(n) / sr
y = bp_noise(n, 150, 900) * np.exp(-t * 14) * 0.7
y += np.sin(2 * np.pi * 200 * t) * np.exp(-t * 30) * 0.5
return dc(y * (1 - np.exp(-t * 400)))
ONE_SHOTS = {
"alarm": r_alarm,
"dodge": r_dodge,
"shield_block": r_shield_block,
"boost": r_boost,
"shield": r_shield,
"whiff": r_whiff,
}
# ---------------------------------------------------------------- 编码 + 真峰值校验
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 spectral_report(base):
"""编码后解码回读:≥3kHz/≥5kHz 能量占比(刺耳复测口径,与取证轮一致)。"""
r = subprocess.run([FFMPEG, "-v", "error", "-i", base + ".ogg", "-f", "f32le", "-ac", "1", "-ar", "44100", "-"],
capture_output=True)
y = np.frombuffer(r.stdout, dtype=np.float32)
Y = np.abs(np.fft.rfft(y * np.hanning(len(y))))
fr = np.fft.rfftfreq(len(y), 1 / sr)
e = Y ** 2
tot = e.sum() + 1e-12
return e[fr >= 3000].sum() / tot * 100, e[fr >= 5000].sum() / tot * 100, fr[np.argmax(Y)] / 1000
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:
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}")
print(f"{'id':14s} {'dur(s)':>7s} {'TP(dBFS)':>9s} {'%>3k':>7s} {'%>5k':>7s} {'peakkHz':>8s} note")
for fid, dur, tp, note in report:
e3, e5, pk = spectral_report(os.path.join(DST, fid))
print(f"{fid:14s} {dur:7.2f} {tp:9.2f} {e3:7.1f} {e5:7.1f} {pk:8.2f} {note}")
if e5 > 15.0:
problems.append(f"{fid}: 5kHz+ 能量 {e5:.1f}% 仍偏高")
if problems:
print("PROBLEMS:")
for p in problems:
print(" ", p)
return 1
print(f"ALL OK: {len(ONE_SHOTS)} one-shot -> {DST}")
return 0
if __name__ == "__main__":
sys.exit(main())