Files
SoundGame/web/teahouse/gen/gen_sfx.py
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

407 lines
13 KiB
Python
Raw 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 -*-
"""《深夜茶馆》程序合成音效(20-teahouse.md §8.2,24 件 SYN;SA3 5 件见 gen_sfx_sa3.py)。
落盘:staging wav → web/teahouse/gen/staging/syn/<id>.wav(44.1kHz 立体声);
成品 → web/teahouse/audio/sfx/<id>.{ogg,m4a}(tools/bin/ffmpeg 双格式)。
one-shot QC:去直流 + 首尾 8ms cos² 淡化 + 编码后解码回读真峰值 ≤ -3dBFS
(MDCT 过冲坑:以 ebur128 peak=true 实测为准,超限自动降档重编)。
本作无 loop sfx(候汤/闷泡时间线 = one-shot 链 + setTimeout 调度,§3.2)。
频带分离(§3.3):沸腾家族低频 150-500Hz / 滴答木鱼 ~880Hz 高频 /
语音中频——偶发瞬时叠听不糊。
用法:.venv-tts/bin/python web/teahouse/gen/gen_sfx.py
"""
import os
import subprocess
import sys
import numpy as np
import soundfile as sf
SR = 44100
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "staging", "syn")
DST = os.path.join(HERE, "..", "audio", "sfx")
FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg"
os.makedirs(OUT, exist_ok=True)
os.makedirs(DST, exist_ok=True)
ONE_SHOT_TP_CEIL = -3.0
SAMPLE_PEAK = 0.66
FADE_N = int(SR * 0.008)
_rng = np.random.default_rng(20)
def dc(y):
return y - np.mean(y)
def fade(y):
w = np.sin(np.linspace(0, np.pi / 2, FADE_N)) ** 2
y = y.copy()
y[:FADE_N] *= w
y[-FADE_N:] *= w[::-1]
return y
def norm(y, peak=SAMPLE_PEAK):
m = np.max(np.abs(y))
return (y / m * peak) if m > 0 else y
def stereo(y):
return np.stack([y, y], axis=1).astype(np.float32)
def place(y, start_s, sig):
s = int(start_s * SR)
n = min(len(sig), len(y) - s)
if n > 0:
y[s:s + n] += sig[:n]
def bp_noise(n, lo, hi):
x = _rng.standard_normal(n)
X = np.fft.rfft(x)
freqs = np.fft.rfftfreq(n, 1 / SR)
mask = ((freqs >= lo) & (freqs <= hi)).astype(float)
y = np.fft.irfft(X * mask, n)
return y / (np.max(np.abs(y)) + 1e-9)
def sine_track(n, f0, f1, amp=1.0, vib_hz=0.0, vib_depth=0.0):
t = np.arange(n) / SR
f = np.linspace(f0, f1, n)
if vib_hz:
f = f * (1 + vib_depth * np.sin(2 * np.pi * vib_hz * t))
ph = 2 * np.pi * np.cumsum(f) / SR
return amp * np.sin(ph)
def knock(n, f, decay, bright=0.3):
"""木/陶质敲击:基频 + 非谐波泛音簇,快起指数衰。"""
t = np.arange(n) / SR
y = np.sin(2 * np.pi * f * t)
for r, g in [(2.71, bright), (4.95, bright * 0.5), (7.9, bright * 0.25)]:
y += g * np.sin(2 * np.pi * f * r * t)
return y * (1 - np.exp(-t * 500)) * np.exp(-t * decay)
# ---------------------------------------------------------------- 渲染
def r_sig_zhou(): # 扳手磕搪瓷缸:金属亮击 + 短铃振
n = int(SR * 0.45)
t = np.arange(n) / SR
y = knock(n, 1240, 22, bright=0.9) * 0.8
y += knock(n, 2870, 30, bright=0.4) * 0.35
return dc(y)
def r_sig_su(): # 指尖叩桌面两下:两声闷木叩,第二声略轻
n = int(SR * 0.50)
y = np.zeros(n)
for st, g in [(0.0, 1.0), (0.22, 0.7)]:
seg_n = n - int(st * SR)
place(y, st, knock(seg_n, 340, 40, bright=0.2) * g)
return dc(y)
def r_sig_shi(): # 拉链+书本落桌:金属擦 + 闷拍
n = int(SR * 0.60)
t = np.arange(n) / SR
zip_n = int(SR * 0.22)
z = bp_noise(zip_n, 2500, 6500) * np.linspace(0.3, 1.0, zip_n) * np.exp(-np.arange(zip_n) / SR * 8)
y = np.zeros(n)
place(y, 0.0, z * 0.5)
book_n = n - int(0.26 * SR)
bt = np.arange(book_n) / SR
book = np.sin(2 * np.pi * 95 * bt) * np.exp(-bt * 35)
book += bp_noise(book_n, 120, 500) * np.exp(-bt * 40) * 0.9
place(y, 0.26, book * 0.9)
return dc(y)
def r_sig_bai(): # 棋子落木盘:轻脆单响(高频木盘)
n = int(SR * 0.30)
return dc(knock(n, 1750, 35, bright=0.6))
def r_sig_he(): # 车钥匙串抖两下:两簇高频金属哗啦
n = int(SR * 0.55)
y = np.zeros(n)
for st, g in [(0.0, 1.0), (0.20, 0.75)]:
seg_n = min(int(SR * 0.16), n - int(st * SR))
seg_t = np.arange(seg_n) / SR
j = np.zeros(seg_n)
for f in [4200, 5300, 6600]:
j += np.sin(2 * np.pi * f * seg_t) * np.exp(-seg_t * (45 + f / 400))
j += bp_noise(seg_n, 3000, 8000) * np.exp(-seg_t * 30) * 0.5
place(y, st, j * g)
return dc(y)
def r_tea_jar(): # 木罐启盖 + 茶叶沙沙:木旋 + 干沙噪声
n = int(SR * 0.35)
t = np.arange(n) / SR
y = knock(n, 520, 28, bright=0.3) * 0.7
leaf_n = n - int(0.10 * SR)
lt = np.arange(leaf_n) / SR
y[int(0.10 * SR):] += bp_noise(leaf_n, 1800, 5200) * np.exp(-lt * 18) * 0.35
return dc(y)
def r_tea_scoop(): # 铜勺入壶:金属轻磕 + 细沙落
n = int(SR * 0.35)
t = np.arange(n) / SR
y = knock(n, 1980, 40, bright=0.7) * 0.7
leaf_n = n - int(0.08 * SR)
lt = np.arange(leaf_n) / SR
y[int(0.08 * SR):] += bp_noise(leaf_n, 2400, 6000) * np.exp(-lt * 25) * 0.3
return dc(y)
def r_boil_blip(): # 单泡音 0.3s:低频咕嘟一枚(150-500Hz 带)
n = int(SR * 0.30)
t = np.arange(n) / SR
f = 320 + 60 * np.sin(np.pi * t / 0.28)
plip = sine_track(n, 300, 380) * np.exp(-((t - 0.09) / 0.08) ** 2)
plip += bp_noise(n, 200, 700) * np.exp(-((t - 0.10) / 0.09) ** 2) * 0.8
return dc(plip)
def r_boil_simmer(): # 低鸣 one-shot 1.6s:密集小泡低鸣(将沸段床)
n = int(SR * 1.60)
t = np.arange(n) / SR
y = bp_noise(n, 150, 600)
# 泡破裂随机调制
am = 0.6 + 0.4 * np.sin(2 * np.pi * 7 * t) * np.sin(2 * np.pi * 1.3 * t)
env = (1 - np.exp(-t * 8)) * np.exp(-np.maximum(t - 1.2, 0) * 4)
rum = sine_track(n, 110, 120, 0.35)
return dc(y * am * env * 0.8 + rum * env)
def r_boil_roar(): # 连片滚沸 2.0s/段:粗重咕嘟(滚透段床)
n = int(SR * 2.00)
t = np.arange(n) / SR
y = bp_noise(n, 150, 500) * (0.7 + 0.3 * np.sin(2 * np.pi * 5.5 * t))
rum = sine_track(n, 90, 100, 0.5)
env = (1 - np.exp(-t * 6)) * np.exp(-np.maximum(t - 1.7, 0) * 5)
# 大泡破裂点缀
bub = np.zeros(n)
for st in [0.35, 0.9, 1.45]:
seg_n = n - int(st * SR)
seg_t = np.arange(seg_n) / SR
place(bub, st, (sine_track(seg_n, 240, 160) + bp_noise(seg_n, 200, 600) * 0.8)
* np.exp(-seg_t * 22) * 0.9)
return dc((y * 0.8 + rum) * env + bub)
def r_boil_over(): # 滚老剧烈嘶鸣 1.2s:高频汽哨 + 溢出沸溅
n = int(SR * 1.20)
t = np.arange(n) / SR
hiss = bp_noise(n, 1200, 4200) * (1 - np.exp(-t * 30))
sput = bp_noise(n, 300, 900) * (0.5 + 0.5 * np.sin(2 * np.pi * 9 * t))
y = hiss * 0.8 + sput * 0.5
env = np.exp(-np.maximum(t - 0.85, 0) * 6)
return dc(y * env)
def r_kettle_lift(): # 提壶离火蒸汽「呲」0.5s
n = int(SR * 0.50)
t = np.arange(n) / SR
y = bp_noise(n, 2000, 5000) * (1 - np.exp(-t * 60)) * np.exp(-t * 7)
y += sine_track(n, 2600, 2400, 0.15) * np.exp(-t * 6)
return dc(y)
def r_steep_rustle(): # 干茶舒展沙沙 2.0s(低响度)
n = int(SR * 2.00)
t = np.arange(n) / SR
y = bp_noise(n, 900, 3600) * (0.5 + 0.5 * np.sin(2 * np.pi * 0.8 * t))
env = (1 - np.exp(-t * 4)) * np.exp(-np.maximum(t - 1.6, 0) * 5)
return dc(y * env * 0.45)
def r_tick_slow(): # 木鱼滴答(疏)0.15s:880Hz 族轻响
n = int(SR * 0.15)
return dc(knock(n, 880, 55, bright=0.5) * 0.8)
def r_tick_fast(): # 木鱼滴答(密)0.12s:880Hz 族,更脆
n = int(SR * 0.12)
return dc(knock(n, 990, 70, bright=0.6) * 0.8)
def r_coin_soft(): # 单枚铜钱落桌:脆金属点 + 微滚
n = int(SR * 0.35)
t = np.arange(n) / SR
y = knock(n, 4650, 60, bright=0.8) * 0.8
y += knock(n, 5900, 80, bright=0.3) * 0.25
return dc(y)
def r_coin_stack(): # 三枚铜钱落桌:三连脆响渐轻
n = int(SR * 0.60)
y = np.zeros(n)
for st, g in [(0.0, 1.0), (0.13, 0.8), (0.26, 0.65)]:
seg_n = n - int(st * SR)
seg_t = np.arange(seg_n) / SR
c = knock(seg_n, 4400 + int(st * 800), 55, bright=0.7) * g
c += bp_noise(seg_n, 3000, 7000) * np.exp(-seg_t * 60) * 0.3 * g
place(y, st, c)
return dc(y)
def r_rep_chime(): # 口碑上行双音:上行两铃(E5→G5)
n = int(SR * 0.55)
y = np.zeros(n)
for st, f in [(0.0, 659.25), (0.16, 783.99)]:
seg_n = n - int(st * SR)
seg_t = np.arange(seg_n) / SR
bell = sum(g * np.sin(2 * np.pi * f * r * seg_t) * np.exp(-seg_t * d)
for r, g, d in [(1, 1.0, 9), (2.76, 0.22, 14)])
place(y, st, bell * 0.55)
return dc(y)
def r_rep_fall(): # 口碑下行双音:下行两铃(E5→C5)
n = int(SR * 0.55)
y = np.zeros(n)
for st, f in [(0.0, 659.25), (0.16, 523.25)]:
seg_n = n - int(st * SR)
seg_t = np.arange(seg_n) / SR
bell = sum(g * np.sin(2 * np.pi * f * r * seg_t) * np.exp(-seg_t * d)
for r, g, d in [(1, 1.0, 9), (2.76, 0.22, 14)])
place(y, st, bell * 0.55)
return dc(y)
def r_lamp_light(): # 开灯「嗒」:拉绳开关脆响
n = int(SR * 0.25)
t = np.arange(n) / SR
y = knock(n, 2100, 90, bright=0.5) * 0.8
y += bp_noise(n, 800, 2500) * np.exp(-t * 90) * 0.4
return dc(y)
def r_lamp_off(): # 熄灯:轻「嗒」+ 余韵下沉
n = int(SR * 0.45)
t = np.arange(n) / SR
y = knock(n, 1500, 70, bright=0.4) * 0.7
y += sine_track(n, 320, 90, 0.2) * np.exp(-t * 8)
return dc(y)
def r_shop_buy(): # 置办:算盘珠一拨 + 钱柜抽屉
n = int(SR * 0.80)
y = np.zeros(n)
for st in [0.0, 0.09, 0.18]: # 算盘三下
seg_n = n - int(st * SR)
place(y, st, knock(seg_n, 2600, 80, bright=0.5) * 0.5)
draw_n = n - int(0.34 * SR)
dt = np.arange(draw_n) / SR
draw = bp_noise(draw_n, 200, 700) * np.minimum(dt * 20, 1) * (0.6 + 0.4 * np.sin(2 * np.pi * 13 * dt))
place(y, 0.34, draw * 0.7)
return dc(y)
def r_upg_install(): # 木工轻敲 ×3
n = int(SR * 0.60)
y = np.zeros(n)
for st, g in [(0.0, 1.0), (0.22, 0.85), (0.44, 0.7)]:
seg_n = n - int(st * SR)
place(y, st, knock(seg_n, 700, 45, bright=0.35) * g)
return dc(y)
def r_turn_tick(): # 看门狗 L1 木鱼轻响(880Hz 族,dungeon 配方)
n = int(SR * 0.12)
return dc(knock(n, 880, 60, bright=0.4) * 0.6)
ONE_SHOTS = {
"sig_zhou": r_sig_zhou,
"sig_su": r_sig_su,
"sig_shi": r_sig_shi,
"sig_bai": r_sig_bai,
"sig_he": r_sig_he,
"tea_jar": r_tea_jar,
"tea_scoop": r_tea_scoop,
"boil_blip": r_boil_blip,
"boil_simmer": r_boil_simmer,
"boil_roar": r_boil_roar,
"boil_over": r_boil_over,
"kettle_lift": r_kettle_lift,
"steep_rustle": r_steep_rustle,
"tick_slow": r_tick_slow,
"tick_fast": r_tick_fast,
"coin_soft": r_coin_soft,
"coin_stack": r_coin_stack,
"rep_chime": r_rep_chime,
"rep_fall": r_rep_fall,
"lamp_light": r_lamp_light,
"lamp_off": r_lamp_off,
"shop_buy": r_shop_buy,
"upg_install": r_upg_install,
"turn_tick": r_turn_tick,
}
def encode(wav_path, base):
for args, ext in [(["-c:a", "libvorbis", "-q:a", "3"], ".ogg"),
(["-c:a", "aac", "-b:a", "128k"], ".m4a")]:
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", wav_path,
"-ar", "44100", *args, base + ext], check=True)
def true_peak_db(path):
r = subprocess.run([FFMPEG, "-i", path, "-af", "ebur128=peak=true", "-f", "null", "-"],
capture_output=True, text=True)
peaks = [float(line.split("Peak:")[1].strip().split()[0])
for line in r.stderr.splitlines() if "Peak:" in line and "dBFS" in line]
return max(peaks) if peaks else float("nan")
def check_tp(base):
return max(true_peak_db(base + ".ogg"), true_peak_db(base + ".m4a"))
def main():
report = []
problems = []
for fid, fn in ONE_SHOTS.items():
wav_path = os.path.join(OUT, fid + ".wav")
base = os.path.join(DST, fid)
y = fade(norm(dc(fn())))
sf.write(wav_path, stereo(y), SR)
encode(wav_path, base)
tp = check_tp(base)
if tp > ONE_SHOT_TP_CEIL: # MDCT 过冲:按超出量降档重编
gain = 10 ** ((ONE_SHOT_TP_CEIL - tp) / 20) * 0.98
sf.write(wav_path, stereo(y * gain), SR)
encode(wav_path, base)
tp2 = check_tp(base)
report.append((fid, len(y) / SR, tp, f"降 {20 * np.log10(gain):.1f}dB 重编 -> {tp2:.2f}"))
tp = tp2
else:
report.append((fid, len(y) / SR, tp, ""))
if tp > ONE_SHOT_TP_CEIL:
problems.append(f"{fid}: 真峰值 {tp:.2f} dBFS 仍超 {ONE_SHOT_TP_CEIL}")
print(f"{'id':14s} {'dur(s)':>7s} {'TP(dBFS)':>9s} note")
for fid, dur, tp, note in report:
print(f"{fid:14s} {dur:7.2f} {tp:9.2f} {note}")
if problems:
print("PROBLEMS:")
for p in problems:
print(" ", p)
return 1
print(f"ALL OK: {len(ONE_SHOTS)} one-shot -> {DST}")
return 0
if __name__ == "__main__":
sys.exit(main())