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

185 lines
7.7 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,18-detective.md §8.2 标注 SYN 的条目)。
6 个 id(其余 6 条为 Stable Audio 3 条目,见 gen_sfx_sa3.py):
sig_shun 小顺签名:布掸子拍柜台两下(双拍软拍,间隔 0.16s)
sig_wen 温太太签名:门环叩两下(金属环叩木门,二连击)
sig_fore 法医签名:纸页翻动 + 钢笔划线(噪声簌 + 笔尖刮擦两段)
contra_hit 指证命中:不和谐金属小二度相击 + 盖章尾(全游戏最强确认音,-4dBFS)
contra_miss 指证落空:钝木鱼一下(非 error 音色、不威吓,-8dBFS)
turn_tick 看门狗 L1:880Hz 木鱼族轻响(dungeon 同配方自存一份,-8dBFS)
签名 earcon 规格:条目切换/指证选择/状态复述时前置(0.3-0.5s 量级),节拍可数
(两下=可辨身份),电平中低不抢人声。
可复现:本脚本入库,一键出 web/detective/audio/sfx/*.{ogg,m4a};中间 wav 在
gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k;
one-shot 真峰值 <= -3dBFS(解码回读法)。
用法:.venv-tts/bin/python web/detective/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"
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=-6.0):
"""mono 存。工艺同 tools/fix_clicks.py:去直流 → 首尾 8ms cos² 淡入淡出 →
峰值归一指定 dBFS(签名 earcon 默认 -6,contra_hit -4,miss/tick -8)。"""
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)
# ---- sig_shun:布掸子拍柜台两下(软布拍击:中频噪声团 + 轻微柜台木腔共鸣) ----
def duster_pat(dur=0.10):
x = t(dur)
pat = bandpass(rng.standard_normal(len(x)), 250, 1600) * env_exp(dur, 0.012, 0.05)
wood = 0.4 * np.sin(2 * np.pi * 190 * x) * np.exp(-x * 55)
return pat + wood
y = np.zeros(int(SR * 0.42), dtype=np.float64)
for i, amp in enumerate([1.0, 0.85]):
k = duster_pat()
s = int(SR * (0.02 + i * 0.16))
y[s:s + len(k)] += k * amp
save("sig_shun", y)
# ---- sig_wen:门环叩两下(金属环叩木门:非谐波金属簇 + 门板低频响应,二连击) ----
def knocker(dur=0.13):
x = t(dur)
ring = sum(a * np.sin(2 * np.pi * fr * x) * np.exp(-x * dc)
for fr, a, dc in [(880, 1.0, 30), (1350, 0.5, 42), (2210, 0.3, 55)])
door = 0.55 * np.sin(2 * np.pi * 120 * x) * np.exp(-x * 40)
thud = lowpass(rng.standard_normal(len(x)), 80) * np.exp(-x * 70) * 0.45
return ring + door + thud
y = np.zeros(int(SR * 0.46), dtype=np.float64)
for i, amp in enumerate([1.0, 0.8]):
k = knocker()
s = int(SR * (0.02 + i * 0.18))
y[s:s + len(k)] += k * amp
save("sig_wen", y)
# ---- sig_fore:纸页翻动 + 钢笔划线(簌簌噪声扫频 + 笔尖刮擦两划) ----
d = 0.30
x = t(d)
page = bandpass(rng.standard_normal(len(x)), 900, 5000) * env_exp(d, 0.02, 0.10) * 0.8 # 翻页簌
y = page.copy()
for start, dur2 in [(0.14, 0.10), (0.21, 0.07)]: # 钢笔两划
seg = t(dur2)
pen = bandpass(np.random.default_rng(int(start * 1000)).standard_normal(len(seg)),
1500, 4500) * env_exp(dur2, 0.01, 0.03) * 0.5
s = int(SR * start)
y[s:s + len(pen)] += pen
save("sig_fore", y)
# ---- contra_hit:不和谐金属小二度相击 + 盖章尾(矛盾证实音,全游戏最强确认音) ----
# 1250/1320Hz 小二度拍频互殴 0.18s -> 顿 0.05s -> 低频盖章闷击(thunk)收尾。
d1 = 0.18
x1 = t(d1)
clash = (np.sin(2 * np.pi * 1250 * x1) + np.sin(2 * np.pi * 1320 * x1)
+ 0.4 * np.sin(2 * np.pi * 2500 * x1) + 0.3 * np.sin(2 * np.pi * 2640 * x1)) * np.exp(-x1 * 13)
d2 = 0.16
x2 = t(d2)
stamp = (np.sin(2 * np.pi * 95 * x2) * np.exp(-x2 * 22)
+ 0.6 * lowpass(rng.standard_normal(len(x2)), 70) * np.exp(-x2 * 40))
save("contra_hit", np.concatenate([clash, np.zeros(int(SR * 0.05)), stamp, np.zeros(int(SR * 0.08))]),
peak_db=-4.0)
# ---- contra_miss:钝木鱼一下(低频短木腔音,非 error 音色) ----
d = 0.16
x = t(d)
save("contra_miss", np.sin(2 * np.pi * 310 * x) * np.exp(-x * 30)
+ 0.5 * np.sin(2 * np.pi * 620 * x) * np.exp(-x * 45)
+ 0.3 * lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 70),
peak_db=-8.0)
# ---- turn_tick:880Hz 木鱼族轻响(dungeon 同配方自存一份;无进展轨 L1 不占台词通道) ----
d = 0.16
x = t(d)
save("turn_tick", np.sin(2 * np.pi * 880 * x) * np.exp(-x * 38)
+ 0.35 * np.sin(2 * np.pi * 1760 * x) * np.exp(-x * 52),
peak_db=-8.0)
def encode(name):
"""staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。
工艺:源尾部垫 50ms 静音(编码器软着陆)+ highpass=f=30(抑制解码直流再生);
纯弦 AAC 过冲按实测迭代补 volume(abyss link_beep 同坑,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_det.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_det.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
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 ["sig_shun", "sig_wen", "sig_fore", "contra_hit", "contra_miss", "turn_tick"]:
encode(_n)
print("detective SYN sfx done: 6 files x2 formats")