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

170 lines
7.4 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 -*-
"""《深海回音》SYN 程序合成音效(批次 2,17-abyss.md §5.2 标注 SYN 的条目)。
6 个 id(其余 12 条为 Stable Audio 3 条目,见 gen_sfx_sa3.py):
sonar_ping 你发出的声呐脉冲:1.4->0.9kHz 正弦 chirp 220ms 指数衰减,-6dBFS
(设计指定电平;「回波家族统一音头」——搜索等待单击恒出此音)
pipe_knock 管路敲击暗号(老白 SOS):三短三长三短固定节拍金属敲击
(节拍精确可复现、断言可数;短 0.12s / 长 0.36s / 间隔 0.12s)
link_beep 电台接通:双音上行 0.3s
ascent_chime 出水确认:上行三音琶音 0.4s
ui_confirm 菜单确认(全局先例:880->1320Hz 双音)
ui_menu 菜单呼出(全局先例:330+495Hz 软音)
可复现:本脚本入库,一键出 web/abyss/audio/sfx/*.{ogg,m4a};中间 wav 在
gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k;
one-shot 真峰值 <= -3dBFS(解码回读法,源归一留 MDCT 过冲余量);无 loop 类条目
(amb_* 三条走 SA3「amb 床只去直流」工艺,在 gen_sfx_sa3.py)。
用法:.venv-tts/bin/python web/abyss/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 lowpass(x, k):
return np.convolve(x, np.ones(k) / k, mode="same")
def bandpass(x, lo, hi):
kh, kl = max(1, int(SR / (np.pi * hi))), max(1, int(SR / (np.pi * lo)))
return lowpass(x, kh) - lowpass(x, kl)
def save(name, sig, peak_db=-4.0):
"""mono 存。工艺同 tools/fix_clicks.py:去直流 → 首尾 8ms cos² 淡入淡出 →
峰值归一指定 dBFS(sonar_ping 按设计 -6dBFS)。"""
sig = sig - np.mean(sig)
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
sig[-nf:] *= fade[::-1]
sig = sig / (np.max(np.abs(sig)) + 1e-9) * 10 ** (peak_db / 20)
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
rng = np.random.default_rng(20260909)
# ---- sonar_ping:声呐脉冲(1.4->0.9kHz 正弦 chirp 220ms 指数衰减,-6dBFS) ----
d = 0.22
x = t(d)
f = 1400 - (1400 - 900) * x / d # 1.4 -> 0.9kHz 线性下滑
chirp = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 14)
click = bandpass(rng.standard_normal(len(x)), 2000, 6000) * np.exp(-x * 60) * 0.2 # 起振细瞬态
save("sonar_ping", 0.92 * chirp + click, peak_db=-6.0)
# ---- pipe_knock:管路敲击暗号(三短三长三短,SOS 节拍精确可复现/可数) ----
# 单击=金属管体振(非谐波簇 640/1490/2330Hz,短促衰减),长短仅持续不同。
def knock(dur, decay=26.0):
x = t(dur)
hit = sum(a * np.sin(2 * np.pi * fr * x) * np.exp(-x * decay * (1 + fr / 3000))
for fr, a in [(640, 1.0), (1490, 0.55), (2330, 0.3), (410, 0.5)])
thud = lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 90) * 0.5
return (hit + thud) * env_exp(dur, 0.002, 0.01)
D_SHORT, D_LONG, GAP = 0.12, 0.36, 0.12
pattern = ([D_SHORT] * 3 + [D_LONG] * 3 + [D_SHORT] * 3) # 三短三长三短
_y = np.zeros(int(SR * (sum(pattern) + GAP * (len(pattern) - 1) + 0.15)), dtype=np.float64)
_pos = 0.15 / 2 * SR
for dur in pattern:
k = knock(dur)
_y[int(_pos):int(_pos) + len(k)] += k
_pos += int((dur + GAP) * SR)
save("pipe_knock", _y)
# ---- link_beep:电台接通双音上行 0.3s(620->930Hz,通讯族音色,与 ui 族区分) ----
d = 0.30
a = np.sin(2 * np.pi * 620 * t(0.12)) * env_exp(0.12, 0.004, 0.02)
b = np.sin(2 * np.pi * 930 * t(0.16)) * env_exp(0.16, 0.004, 0.06)
save("link_beep", np.concatenate([a, np.zeros(int(SR * 0.02)), b]))
# ---- ascent_chime:出水确认上行三音琶音 0.4s(C5-E5-G5,干净上行=「上浮完成」) ----
y = np.zeros(int(SR * 0.40), dtype=np.float64)
for i, fr in enumerate([523.25, 659.25, 783.99]):
seg = t(0.22)
tone = (np.sin(2 * np.pi * fr * seg) + 0.4 * np.sin(2 * np.pi * fr * 2 * seg)) * np.exp(-seg * 10)
s = int(SR * i * 0.09)
n = min(len(tone), len(y) - s)
y[s:s + n] += tone[:n] * 0.6
save("ascent_chime", y)
# ---- ui_confirm / ui_menu:全局先例同款(storyline/radio gen_sfx.py) ----
a = np.sin(2 * np.pi * 880 * t(0.09)) * env_exp(0.09, 0.005, 0.003)
b = np.sin(2 * np.pi * 1320 * t(0.14)) * env_exp(0.14, 0.005, 0.012)
save("ui_confirm", np.concatenate([a, np.zeros(int(SR * 0.02)), b, np.zeros(int(SR * 0.05))]))
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))]))
def encode(name):
"""staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。
工艺:源尾部垫 50ms 静音(编码器软着陆)+ highpass=f=30(抑制 MDCT/AAC 解码直流再生);
纯弦 AAC 过冲按实测迭代补 volume(link_beep.m4a 首轮实测 -2.82dBFS 超限,dungeon 补偿同款)。"""
src = os.path.join(STAGING, name + ".wav")
base, _sr0 = sf.read(src, always_2d=True)
pad_t = np.zeros((int(SR * 0.05), base.shape[1]), dtype=np.float64)
base = np.concatenate([base, pad_t])
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}")
vol, dc_adj = 0.0, 0.0
tmp = "/tmp/_sfx_enc_abyss.wav"
for _attempt in range(4):
sig = base - dc_adj
if vol < 0:
sig = sig * (10 ** (vol / 20))
sf.write(tmp, sig.astype(np.float32), SR)
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", tmp, "-af", "highpass=f=30",
"-ar", "44100", *cmd, out], check=True)
chk = "/tmp/_sfx_qc_abyss.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)
dcv = float(np.mean(y.mean(axis=1)))
if tp <= -3.0 and abs(dcv) <= 0.001:
break
if tp > -3.0:
vol -= (tp + 3.0) + 0.25 # 超限差值 + 0.25dB 余量
if abs(dcv) > 0.001:
dc_adj -= dcv
else:
raise AssertionError(f"{name}.{ext}: 补偿后仍超限 tp={tp:.2f}dBFS dc={dcv:.4f}")
print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS" + (f" (vol {vol:+.2f}dB)" if vol else ""))
for _n in ["sonar_ping", "pipe_knock", "link_beep", "ascent_chime", "ui_confirm", "ui_menu"]:
encode(_n)
print("abyss SYN sfx done: 6 files x2 formats")