Files
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

204 lines
10 KiB
Python
Raw Permalink 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,17-abyss.md §5.2 SA3 条目)。
生成(GPU,flock 串行;模型 gated 走 hf-mirror,见 env.d/sfx.sh):
python3 gen_sfx_sa3.py --emit # 打印生成命令,原样执行
生成 12 条原始 wav 到 gen/staging/sfx_sa3/abyss_<i>.wav(顺序 = 下表 idx)。
本脚本随后做后处理:修整/去直流/8ms cos² 淡化(amb 床只去直流不淡化——游戏层
buffer.loop 循环,radio phone_ring/amb 先例)/ 响度定档 → ogg q4 + m4a 128k
(highpass=f=30,one-shot 前后垫 60/120ms 静音)→ 解码回读 QC:逐声道真峰值
<= -3dBFS、|直流| <= 0.001;超限降增益 + 首轮重编加 18kHz 低通,最多 3 轮补偿。
echo_metal_l/_r:文档 prompt 带 panned slightly left/right,但引擎无运行时 pan、
SA3 立体声分离度不可靠(dungeon 先例)——取「left」版生成源,mono 下混后 ffmpeg
pan 派生 _l/_r 预 pan 立体声(能量只在目标声道,脚本断言极性;dungeon side_* 工艺)。
prompt 均出自 17-abyss.md §5.2(英文原稿);定档理由:
spark_flash/landslide -4(D2/D4 危机拍,前置 2s 渐强同窗)
echo_metal -5 / echo_groan -6 / surf_splash -6(信息音/叙事音,主层次)
valve_close -5 / bell_toll -5(道具 payoff 音)
echo_debris -7 / static_burst -8(垫在通话底下的纹理层)
amb_* RMS -26 / 峰值帽 -8(环境床,escape/adio amb 先例)
用法:.venv-tts/bin/python web/abyss/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, 模式 oneshot/amb, 定档 dBFS, trim 上限 s)
ITEMS = [
("echo_metal", 3.0, "Sonar echo returning from a steel submarine hull, resonant metallic ping with slow decay, panned slightly left, underwater, 2 seconds", "oneshot", -5.0, 2.4),
("echo_debris", 3.0, "Sonar echo returning from an underwater rubble and debris field, diffuse granular reverberation, 2 seconds", "oneshot", -7.0, 2.6),
("echo_groan", 5.0, "Stressed submarine hull groaning deep underwater, long metallic moan under immense pressure, 4 seconds", "oneshot", -6.0, 4.5),
("static_burst", 3.0, "Shortwave radio static burst with faint ghost of a distorted voice, 2 seconds", "oneshot", -8.0, 2.4),
("amb_deep", 12.0, "Deep ocean ambient loop, low pressure hum, distant current wash, occasional far creak, seamless loop", "amb", -26.0, 0),
("amb_deck", 12.0, "Night ship deck ambience, wind, gentle waves against hull, distant rigging creak and engine hum, seamless loop", "amb", -26.0, 0),
("breath_loop", 12.0, "Slow scuba regulator breathing loop, calm but slightly labored, underwater, seamless loop", "amb", -26.0, 0),
("spark_flash", 3.0, "Underwater cutting torch sparks with a brief muffled gas pop, 2 seconds", "oneshot", -4.0, 2.6),
("valve_close", 3.0, "Heavy submarine valve wheel shutting, metal rotation clunk and water seal, 2 seconds", "oneshot", -5.0, 2.4),
("landslide", 6.0, "Underwater landslide rumble, rolling sediment and rocks, ominous low roar building, 4 seconds", "oneshot", -4.0, 5.5),
("surf_splash", 3.0, "Diver breaking through the sea surface, splash and one gasp, 2 seconds", "oneshot", -6.0, 2.6),
("bell_toll", 4.0, "Old ship brass bell, single clean toll with warm resonance, 3 seconds", "oneshot", -5.0, 3.5),
]
# 事件窗(SA3 raw 为定长 latent 床,与请求时长无关;None=按 -50dBFS 活动窗自然修剪)。
# 首轮先自然修剪,实测事件位置偏离设计时再回填窗(radio arg_muffled 同法)。
WIN = {}
def emit():
items = [it for it in ITEMS if it[3] != "skip"]
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}/abyss.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)) # 窗切在持续声中,切点 50ms 缓出
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] # 8ms cos² 淡入淡出
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 prep_amb(y, rms_db, peak_db):
"""环境床:只去直流,不淡化(游戏层 buffer.loop 循环播放);RMS 定档 + 峰值帽。"""
y = y - np.mean(y, axis=0, keepdims=True)
rms = 20 * np.log10(np.sqrt(np.mean(y ** 2)) + 1e-9)
peak = 20 * np.log10(np.max(np.abs(y)) + 1e-9)
s = min(10 ** ((rms_db - rms) / 20), 10 ** ((peak_db - peak) / 20))
return y * s
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): # 首发 + 最多 3 轮补偿(首轮重编加 18kHz 低通)
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 # 首轮重编起加 18kHz 低通压 MDCT 过冲
if not ok_dc:
dc_adj -= dcv
break
else:
break
else:
raise AssertionError(f"{name}: 补偿 3 轮后仍超限 {res}")
return res
def pan_derive(name, sig_mono):
"""mono 源 -> 预 pan 立体声 _l/_r(能量只在 L 或 R 声道),ffmpeg pan 派生
(dungeon side_* 工艺复用:引擎 play() 无实时 pan)。"""
src = os.path.join(STAGING, name + "_mono.wav")
sf.write(src, sig_mono.astype(np.float32), SR)
for side, out_ch in [("l", "c0"), ("r", "c1")]:
out = os.path.join(STAGING, f"{name}_{side}.wav")
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src,
"-af", f"pan=stereo|{out_ch}=c0", "-c:a", "pcm_f32le", out], check=True)
# 极性断言(staging 源级):能量只在声像侧声道
for side, ch in [("l", 0), ("r", 1)]:
y, _ = sf.read(os.path.join(STAGING, f"{name}_{side}.wav"), always_2d=True)
assert np.max(np.abs(y[:, 1 - ch])) < 1e-6, f"{name}_{side}: 非目标声道有能量"
assert np.max(np.abs(y[:, ch])) > 0.1, f"{name}_{side}: 目标声道无能量"
def main():
os.makedirs(STAGING, exist_ok=True)
if "--emit" in sys.argv:
emit()
return
print(f"| {'id':14s} | {'时长s':>6s} | {'ogg峰dBFS':>9s} | {'m4a峰dBFS':>9s} | {'RMS dBFS':>8s} | 直流 |")
idx = 0
for name, _d, _p, mode, tgt, max_s in ITEMS:
if mode == "skip":
continue
raw = os.path.join(STAGING, f"abyss_{idx}.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)
idx += 1
if mode == "oneshot":
sig = prep_oneshot(y, tgt, win=WIN.get(name), max_s=max_s)
else:
sig = prep_amb(y, tgt, -8.0)
if name == "echo_metal":
# mono 下混 -> pan 派生 _l/_r(保留 oneshot 修整/淡化/定档后的信号)
mono = sig.mean(axis=1)
pad_h = np.zeros((int(SR * 0.06), 2))
pad_t = np.zeros((int(SR * 0.12), 2))
pan_derive(name, mono)
for side in ("l", "r"):
s2, _ = sf.read(os.path.join(STAGING, f"{name}_{side}.wav"), always_2d=True)
res = encode(f"echo_metal_{side}", np.concatenate([pad_h, s2, pad_t]))
tp_o, dc, rms, dur = res["ogg"]
print(f"| echo_metal_{side:<3s} | {dur:6.2f} | {tp_o:9.2f} | {res['m4a'][0]:9.2f} | {rms:8.2f} | {dc:+.4f} |")
continue
res = encode(name, sig)
tp_o, dc, rms, dur = res["ogg"]
tp_m = res["m4a"][0]
print(f"| {name:14s} | {dur:6.2f} | {tp_o:9.2f} | {tp_m:9.2f} | {rms:8.2f} | {dc:+.4f} |")
print("abyss SA3 sfx done -> web/abyss/audio/sfx/")
if __name__ == "__main__":
main()