Files
SoundGame/web/detective/gen/gen_sfx_sa3.py
T
xsl 31372615b2 feat(new8/batch2): 《深海回音》abyss +《雨夜专案》detective 全套交付(多agent流水线:策划→资产→开发→测试→真资产复跑)
- abyss:game.js 242 断言全绿(剧本A-F六路径/氧气门三形态/sonarGate仲裁/结算死窗/双轨看门狗/radio 8项修复继承);资产 148句+11key变体(电台滤波带内能量47-62%→69-80%)
- detective:game.js 265 断言真资产下全绿(矩阵不变式独立重算/指证全路径/评级12组注入/VERDICT死窗/二周目变体);真资产复跑回炉3处实现修复(无进展轨墙钟化/菜单暂停记账/停止播报真停声——mock时长掩盖的正确性bug)
- 资产:TTS 539条×2格式、Mureka 7首 $0.21(累计$1.08/$2.00)、SYN12+SA3 19件(QC抓出1例AAC过冲并修复)、封面×2、check_all_assets 10游戏全绿(abyss 298/298、detective 279/279)
- 平台接线:大厅双卡片、BUCKETS 增 abyss/detective 桶(H4 断言更新 10 游戏)、voice_text.json 10游戏991句
- 回归:14 套件全绿(10 playtest+guide+engine-voice+engine-input+api-smoke 12/12)
- 设计偏差登记:abyss 7条/detective 9条(全为设计内部冲突按承诺清单/归因真话律取优)
2026-09-10 01:40:31 +08:00

144 lines
6.1 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 -*-
"""《雨夜专案》Stable Audio 3 small-sfx 音效(批次 2,18-detective.md §8.2 SA3 条目)。
生成(GPU,flock 串行;模型 gated 走 hf-mirror,见 env.d/sfx.sh):
python3 gen_sfx_sa3.py --emit # 打印生成命令,原样执行
生成 6 条原始 wav 到 gen/staging/sfx_sa3/detective_<i>.wav(顺序 = 下表 idx)。
本脚本随后做后处理:修整(签名 earcon 按 max_s 压进 0.3-1.0s 量级——条目切换/
指证选择前置音,长了拖慢导航)/去直流/8ms cos² 淡化/ 响度定档 → ogg q4 +
m4a 128k(highpass=f=30,one-shot 前后垫 60/120ms 静音)→ 解码回读 QC:逐声道
真峰值 <= -3dBFS、|直流| <= 0.001;超限降增益 + 首轮重编加 18kHz 低通,最多 3 轮。
prompt:paper_turn/gavel 直出文档英文原稿;sig_ge/sig_zhou/sig_scene/rain_swell
为文档中文拟声条的英文化(信源:18-detective.md §8.2)。定档:
gavel -4(裁决仪式拍)、paper_turn -7(导航细节反馈)
sig_* -6(签名前置音,可辨不抢人声)、rain_swell -10(认罪链垫,压在 bgm/verdict 下)
用法:.venv-tts/bin/python web/detective/gen/gen_sfx_sa3.py [--emit]
"""
import os
import subprocess
import sys
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_sa3")
DST = os.path.join(HERE, "..", "audio", "sfx")
SA3 = "/home/xsl/SoundGame/tools/stable-audio-3"
FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg"
SEED = 20260909
# (id, gen 秒, prompt, 定档 dBFS, trim 上限 s)
ITEMS = [
("paper_turn", 3.0, "single paper page turn on desk, dry, close", -7.0, 0.9),
("sig_ge", 3.0, "Enamel mug thumped down on a wooden desk, single dull metallic knock, close", -6.0, 0.7),
("sig_zhou", 3.0, "Wooden abacus beads flicked twice, dry clacking, close up", -6.0, 0.9),
("sig_scene", 4.0, "Leather shoes stepping through rain puddles twice, then one umbrella shake, night rain, close", -6.0, 1.1),
("gavel", 4.0, "wooden gavel single strike, small room reverb", -4.0, 3.0),
("rain_swell", 9.0, "Night rain swelling heavier then gradually settling, steady rain on windows and wet street, 6 seconds", -10.0, 7.0),
]
WIN = {} # 事件窗(raw 定长 latent 床):首轮自然修剪,实测偏离再回填
def emit():
durs = " ".join(str(d) for _, d, *_ in ITEMS)
ps = " ".join(f'"{p}"' for _, _, p, *_ in ITEMS)
print(f"cd {SA3} && flock /home/xsl/SoundGame/.gpu.lock .venv/bin/stable-audio "
f"--model small-sfx --steps 8 --seed {SEED} "
f"-p {ps} --duration {durs} -o {STAGING}/detective.wav")
def trim_oneshot(y, floor_db=-50.0, head_ms=50, tail_ms=140, max_s=None):
env = np.max(np.abs(y), axis=1)
act = np.where(env > 10 ** (floor_db / 20))[0]
a = max(0, act[0] - int(SR * head_ms / 1000))
b = min(len(y), act[-1] + int(SR * tail_ms / 1000))
if max_s:
b = min(b, a + int(SR * max_s))
return y[a:b]
def prep_oneshot(y, target_db, win=None, max_s=None):
if win:
a, b = int(SR * win[0]), int(SR * win[1])
y = y[a:b]
nf2 = max(1, int(SR * 0.050))
y[-nf2:] *= (0.5 * (1 + np.cos(np.pi * np.arange(nf2) / nf2)))[:, None]
else:
y = trim_oneshot(y, max_s=max_s)
y = y - np.mean(y, axis=0, keepdims=True)
nf = max(1, int(SR * 0.008))
fade = 0.5 * (1 - np.cos(np.pi * np.arange(nf) / nf))
y[:nf] *= fade[:, None]
y[-nf:] *= fade[::-1][:, None]
y = y / (np.max(np.abs(y)) + 1e-9) * 10 ** (target_db / 20)
pad_h = np.zeros((int(SR * 0.06), y.shape[1]))
pad_t = np.zeros((int(SR * 0.12), y.shape[1]))
return np.concatenate([pad_h, y, pad_t])
def encode(name, sig):
base = sig.astype(np.float64)
vol, lpf, dc_adj = 0.0, False, 0.0
tmp = os.path.join(STAGING, "_enc.wav")
chk = os.path.join(STAGING, "_qc.wav")
res = {}
for attempt in range(4):
x = base - dc_adj
if vol < 0:
x = x * (10 ** (vol / 20))
sf.write(tmp, x.astype(np.float32), SR)
af = "highpass=f=30,lowpass=f=18000" if lpf else "highpass=f=30"
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", tmp, "-af", af,
"-ar", "44100", *cmd, out], check=True)
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)
dcv = float(np.mean(y.mean(axis=1)))
rms = 20 * np.log10(np.sqrt(np.mean(y ** 2)) + 1e-9)
ok_tp, ok_dc = tp <= -3.0, abs(dcv) <= 0.001
res[ext] = (tp, dcv, rms, y.shape[0] / sr)
if not (ok_tp and ok_dc):
if not ok_tp:
vol -= (tp + 3.0) + 0.25
lpf = True
if not ok_dc:
dc_adj -= dcv
break
else:
break
else:
raise AssertionError(f"{name}: 补偿 3 轮后仍超限 {res}")
return res
def main():
os.makedirs(STAGING, exist_ok=True)
if "--emit" in sys.argv:
emit()
return
print(f"| {'id':12s} | {'时长s':>6s} | {'ogg峰dBFS':>9s} | {'m4a峰dBFS':>9s} | {'RMS dBFS':>8s} | 直流 |")
for i, (name, _d, _p, tgt, max_s) in enumerate(ITEMS):
raw = os.path.join(STAGING, f"detective_{i}.wav")
y, sr = sf.read(raw, always_2d=True, dtype="float64")
assert sr == SR, f"{name}: 采样率 {sr}"
if y.shape[1] == 1:
y = np.stack([y[:, 0], y[:, 0]], axis=1)
sig = prep_oneshot(y, tgt, win=WIN.get(name), max_s=max_s)
res = encode(name, sig)
tp_o, dc, rms, dur = res["ogg"]
print(f"| {name:12s} | {dur:6.2f} | {tp_o:9.2f} | {res['m4a'][0]:9.2f} | {rms:8.2f} | {dc:+.4f} |")
print("detective SA3 sfx done -> web/detective/audio/sfx/")
if __name__ == "__main__":
main()