#!/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/.wav(44.1kHz 立体声); 成品 → web/teahouse/audio/sfx/.{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())