#!/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())