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