- 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条(全为设计内部冲突按承诺清单/归因真话律取优)
204 lines
10 KiB
Python
204 lines
10 KiB
Python
#!/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()
|