Files
SoundGame/web/radio/gen/gen_sfx.py
T
xsl 24c526af68 feat(assets): SA3 音效补齐 radio 11+1 与 dungeon 13源+15pan(token 走 hf-mirror 实测可用)
- 模型 17/17 文件经镜像全量缓存(gated+token,直连本机不通;sfx.md/setup.md/asset-pipeline.md 镜像结论同步更新)
- 修复迁移遗留:stable_audio_3/models/ 源码子包被 .gitignore models/ 规则误伤丢失,从上游恢复并加白名单
- radio:arg_muffled/brake_screech 等叙事件落地(接线原有 L111/L154 零改动)+ phone_ring SYN→SA3 升级;dungeon 13 源 A/B 对齐(RMS 偏差 ≤0.3dB)全量同名替换,SYN 原版备份 tools/asset_fix_backup/sa3_ab_20260902/
- 关键玩法语义达标:echo 距离档 0.83<1.43<2.18<2.98s 严格递增;one-shot 真峰值全 ≤-3dBFS(解码回读法)
- check_all_assets 8 游戏全绿(radio 254/254、dungeon 303/303);playtest radio 221/dungeon 1610 全绿
2026-09-02 19:35:38 +08:00

130 lines
6.3 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 -*-
"""《不眠频率》SYN 程序合成音效(批次 1 + R2 F3,15-radio.md §5.2 标注「程序合成」的条目)。
R2 起共 3 件(其余 11 件为 Stable Audio 3 条目,HF token 未配置,本轮跳过——见批次资产报告):
ui_confirm 菜单/接听确认(同全局先例:storyline gen_sfx.py 的 880->1320Hz 双音)
ui_menu 菜单呼出(同全局先例:330+495Hz 软音)
phone_ring RING 铃声循环垫(R2 F3:440+480Hz 柔和双音一响 1.8s + 静默 3.4s,
循环周期 5.2s ≥4s 烘进文件本身;无缝 loop 规格:逐声道去直流、
首尾**不做淡化**、循环边界静音校验;游戏侧 src.loop=true 循环播放,
接听即停——SA3 真铃声到位后可整文件替换,接口不变)
※ 2026-09-02 更新:SA3 12 件已由 gen_sfx_sa3.py 补产落地,phone_ring 已被 SA3 真铃声
同名替换(本脚本重跑会以 SYN 版覆盖回去——如需回退再跑本脚本即可,原 SYN 备份在
tools/asset_fix_backup/sa3_ab_20260902/radio/)。
可复现:本脚本入库,一键出 web/radio/audio/sfx/ui_{confirm,menu,phone_ring}.{ogg,m4a};
中间 wav 在 gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k;
one-shot 真峰值 <= -3dBFS(解码回读法校验,源归一 -4dBFS 留 MDCT 过冲余量)。
用法:.venv-tts/bin/python web/radio/gen/gen_sfx.py (纯 CPU,无 GPU 依赖)
"""
import os
import subprocess
import numpy as np
import soundfile as sf
SR = 44100
HERE = os.path.dirname(os.path.abspath(__file__))
STAGING = os.path.join(HERE, "staging", "sfx")
DST = os.path.join(HERE, "..", "audio", "sfx")
FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg"
PEAK_SRC = 10 ** (-4.0 / 20) # 源归一 -4dBFS(有损编码过冲余量)
PEAK_LIMIT = 10 ** (-3.0 / 20) # 解码回读上限 -3dBFS
os.makedirs(STAGING, exist_ok=True)
os.makedirs(DST, exist_ok=True)
def t(d):
return np.arange(int(SR * d)) / SR
def env_exp(d, attack, release):
n = int(SR * d)
e = np.ones(n)
na, nr = max(1, int(SR * attack)), max(1, int(SR * release))
e[:na] = np.linspace(0, 1, na)
e[-nr:] *= np.linspace(1, 0, nr)
return e
def save(name, sig):
"""立体声存。工艺同 tools/fix_clicks.py:逐声道去直流 → 首尾 8ms cos² 淡入淡出 → 峰值归一 -4dBFS。"""
if sig.ndim == 1:
sig = np.stack([sig, sig], axis=1)
sig = sig - np.mean(sig, axis=0, keepdims=True)
nf = max(1, int(SR * 0.008))
fade = 0.5 * (1 - np.cos(np.pi * np.arange(nf) / nf))
sig = sig.astype(np.float64).copy()
sig[:nf] *= fade[:, None]
sig[-nf:] *= fade[::-1][:, None]
sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
def save_loop(name, sig):
"""无缝 loop 立体声存(R2 F3):逐声道去直流 → **不做首尾淡化** → 峰值归一 -4dBFS。
循环边界必须落在静音(信号首尾各留 ≥0.3s 零块),loop 拼接零接缝零爆点。"""
if sig.ndim == 1:
sig = np.stack([sig, sig], axis=1)
sig = sig - np.mean(sig, axis=0, keepdims=True)
sig = sig.astype(np.float64).copy()
sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
# ---- ui_confirm:菜单/接听确认(全局先例双音;尾部留 50ms 静音给编码器软着陆) ----
d1, d2 = 0.09, 0.14
a = np.sin(2 * np.pi * 880 * t(d1)) * env_exp(d1, 0.005, 0.003)
b = np.sin(2 * np.pi * 1320 * t(d2)) * env_exp(d2, 0.005, 0.012)
save("ui_confirm", np.concatenate([a, np.zeros(int(SR * 0.02)), b, np.zeros(int(SR * 0.05))]))
# ---- ui_menu:菜单呼出(全局先例软音) ----
d = 0.35
x = t(d)
save("ui_menu", np.concatenate([
(np.sin(2 * np.pi * 330 * x) + 0.5 * np.sin(2 * np.pi * 495 * x)) * env_exp(d, 0.02, 0.08),
np.zeros(int(SR * 0.05))]))
# ---- phone_ring:RING 铃声循环垫(R2 F3)----
# 440+480Hz 经典回铃双频(柔和纯弦叠加),一响 1.8s + 静默 3.4s = 5.2s 循环周期
# (≥4s,原 RING_INTERVAL_MS 节奏烘进文件)。无缝 loop:起振 60ms 线性软起音
# (首样本严格过零)+ 500ms 缓释,循环拼接点两侧均为静音零样本。
_ding, _rest = 1.8, 3.4
_xr = t(_ding)
_ring = (np.sin(2 * np.pi * 440.0 * _xr) + np.sin(2 * np.pi * 480.0 * _xr)) * 0.5
_ring = _ring * env_exp(_ding, 0.06, 0.5)
save_loop("phone_ring", np.concatenate([_ring, np.zeros(int(SR * _rest))]))
def encode(name, loop=False):
"""staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。
工艺:源尾部垫 50ms 静音(编码器软着陆,one-shot)+ highpass=f=30(抑制 MDCT/AAC 解码直流再生)。
loop=True(无缝循环垫)追加边界校验:解码后首/末样本逐声道 |amp| <= -60dBFS——
循环拼接点两侧均为零样本,src.loop=true 拼接零爆点(不做首尾淡化,见 save_loop)。"""
src = os.path.join(STAGING, name + ".wav")
for ext, cmd in [("ogg", ["-c:a", "libvorbis", "-q:a", "4"]),
("m4a", ["-c:a", "aac", "-b:a", "128k"])]:
out = os.path.join(DST, f"{name}.{ext}")
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src, "-af", "highpass=f=30",
"-ar", "44100", *cmd, out], check=True)
chk = "/tmp/_sfx_qc.wav"
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True)
y, sr = sf.read(chk, always_2d=True)
tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9) # 逐声道峰值
assert tp <= -3.0, f"{name}.{ext}: 真峰值 {tp:.2f}dBFS 超限"
assert abs(float(np.mean(y.mean(axis=1)))) <= 0.001, f"{name}.{ext}: 直流超限"
if loop:
edge = 10 ** (-60.0 / 20)
assert float(np.max(np.abs(y[0, :]))) <= edge, f"{name}.{ext}: 循环头样本非静音"
assert float(np.max(np.abs(y[-1, :]))) <= edge, f"{name}.{ext}: 循环尾样本非静音"
print(f" loop {y.shape[0] / sr:.2f}s 边界静音 ✓")
print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS")
for _n in ["ui_confirm", "ui_menu"]:
encode(_n)
encode("phone_ring", loop=True)
print("radio SYN sfx done")