#!/usr/bin/env python3 # -*- coding: utf-8 -*- """R6 缺口 sfx(docs/design/11-rpg-battle-rebalance.md §5):turn_player / victory。 - turn_player:程序合成(numpy)。上行双音(五声 E5→A5 拨弦质感),≤0.4s, "轮到你"的听觉标点,每回合出现,必须极短不烦。 - victory:Stable Audio 3 生成(见 gen_gap_sfx.sh,英文 prompt 照录 11 文档: "triumphant short brass fanfare, 3 rising notes, wuxia style, under 1.5 seconds"), 本脚本负责后处理:去头静音 + 定长 1.2s。 用法: bash web/rpg/gen/gen_gap_sfx.sh # SA3 生成 victory staging(flock GPU) .venv-tts/bin/python web/rpg/gen/gen_gap_sfx.py # 合成 turn_player + 两者后处理落盘 后处理标准(docs/audio/asset-manifest.md R6 起):one-shot 去直流 → 首尾 8ms cos² 淡入淡出 → 峰值 -3dBFS(ogg 解码回读真峰值校验,超 -3dB 降增益重编,首轮附带 18kHz 低通,最多 3 次迭代)。落盘 web/rpg/audio/sfx/.{ogg,m4a}(q3 / aac 128k)。 """ import os import subprocess import numpy as np import soundfile as sf ROOT = "/home/ubuntu/SoundGame" STAGING = os.path.join(ROOT, "web", "rpg", "gen", "staging", "sfx") OUT = os.path.join(ROOT, "web", "rpg", "audio", "sfx") FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg") FADE_MS = 8 PEAK_TARGET = 10 ** (-3.0 / 20) # -3dBFS TRIM = {"victory": 1.2} # 去头后定长(秒);turn_player 合成即定长不裁 def synth_turn_player(path, sr=44100): """上行双音拨弦:E5(659.25) → A5(880),各 0.16s,总 0.36s。 基波 + 2/3 次谐波指数衰减模拟拨弦亮度,避免纯正弦的"提示音"廉价感。""" def pluck(freq, dur, sr): t = np.arange(int(sr * dur)) / sr env = np.exp(-t * 18) y = (np.sin(2 * np.pi * freq * t) + 0.45 * np.sin(2 * np.pi * freq * 2 * t) + 0.18 * np.sin(2 * np.pi * freq * 3 * t)) * env return y n1 = pluck(659.25, 0.16, sr) n2 = pluck(880.00, 0.20, sr) y = np.concatenate([n1, n2]).astype(np.float32) sf.write(path, y, sr) print(f"[synth] turn_player {len(y) / sr:.2f}s") def head_trim(y, sr, thresh_db=-45): """去头静音(5ms 包络,阈值 -45dB,同 R4/R6 工艺)。""" mono = np.abs(y.mean(axis=1)) win = int(sr * 0.005) env = np.convolve(mono, np.ones(win) / win, mode="same") start = int(np.argmax(env > 10 ** (thresh_db / 20))) return y[start:] def fix_array(y, sr): """one-shot 标准:首尾 8ms cos² 淡入淡出 → 峰值归一 -3dBFS → 去直流。""" y = y.astype(np.float64) n = int(sr * FADE_MS / 1000) ramp = np.sin(np.linspace(0, np.pi / 2, n)) ** 2 y[:n] *= ramp[:, None] if y.ndim > 1 else ramp y[-n:] *= ramp[::-1][:, None] if y.ndim > 1 else ramp[::-1] peak = np.max(np.abs(y)) if peak > 0: y = y / peak * PEAK_TARGET y = y - y.mean(axis=0) return y.astype(np.float32) def encode(wav_path, base): """编码 ogg+m4a;ogg 解码回读真峰值校验(MDCT 过冲根因见 round6-report §1), 过冲则降增益重编(首轮附带 18kHz 低通),最多 3 次。返回解码实测峰值 dB。""" subprocess.run([FFMPEG, "-y", "-v", "error", "-i", wav_path, "-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True) cur = wav_path peak_db = None for attempt in range(3): subprocess.run([FFMPEG, "-y", "-v", "error", "-i", cur, "-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True) dec, _sr = sf.read(base + ".ogg", always_2d=True) peak_db = 20 * np.log10(max(float(np.max(np.abs(dec))), 1e-9)) if peak_db <= -2.7: break gain_db = -3.2 - peak_db nxt = wav_path + f".tp{attempt}.wav" af = ("lowpass=f=18000," if attempt == 0 else "") + f"volume={gain_db:.2f}dB" subprocess.run([FFMPEG, "-y", "-v", "error", "-i", cur, "-af", af, "-c:a", "pcm_f32le", nxt], check=True) cur = nxt return peak_db def main(): os.makedirs(STAGING, exist_ok=True) os.makedirs(OUT, exist_ok=True) tp_staging = os.path.join(STAGING, "turn_player.wav") if not os.path.exists(tp_staging): synth_turn_player(tp_staging) for fid in ("turn_player", "victory"): src = os.path.join(STAGING, fid + ".wav") if not os.path.exists(src): print(f"[WARN] 缺 staging {fid}.wav(victory 需先跑 gen_gap_sfx.sh 生成)") continue y, sr = sf.read(src, always_2d=True) y = head_trim(y, sr) if fid in TRIM: y = y[:int(sr * TRIM[fid])] y = fix_array(y, sr) fixed = os.path.join(STAGING, fid + ".fixed.wav") sf.write(fixed, y, sr) base = os.path.join(OUT, fid) peak_db = encode(fixed, base) dur = len(y) / sr mono = y.mean(axis=1) print(f"[ok] {fid} dur={dur:.2f}s true_peak={peak_db:.2f}dBFS " f"dc={float(np.mean(mono)):.5f} -> {base}.{{ogg,m4a}}") assert dur <= 0.4 if fid == "turn_player" else dur <= 1.2, f"{fid} 超长 {dur:.2f}s" assert peak_db <= -2.7, f"{fid} 真峰值超标 {peak_db:.2f}dB" if __name__ == "__main__": main()