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条(全为设计内部冲突按承诺清单/归因真话律取优)
This commit is contained in:
xsl
2026-09-10 01:40:31 +08:00
parent cd4c5ec65a
commit 31372615b2
1183 changed files with 8445 additions and 10 deletions
+143
View File
@@ -0,0 +1,143 @@
#!/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()