四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
407 lines
13 KiB
Python
407 lines
13 KiB
Python
#!/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())
|