#!/usr/bin/env python3 # -*- coding: utf-8 -*- """《暗码》程序合成音效(19-cipher.md §6.2,12 件 SYN 全部程序合成兜底)。 落盘:staging wav → tools/gen_cipher_syn_wav/.wav(44.1kHz 立体声); 成品 → web/cipher/audio/sfx/.{ogg,m4a}(tools/bin/ffmpeg 双格式)。 one-shot QC(10 件):去直流 + 首尾 8ms cos² 淡化 + 编码后解码回读真峰值 ≤ -3dBFS (MDCT 过冲坑:以 ebur128 peak=true 实测为准,超限自动降档重编)。 loop QC(2 件:static_bed 4s / amb_room 8s):只去直流(首尾禁淡化),事件按 循环周期网格化保证循环点相位连续,另做真峰值校验只报告不强制;无缝循环由 游戏层 buffer.loop=true 承担(19-cipher.md §3.4,radio _ringSrc 先例)。 19-cipher.md §6.2 原标 SA3 的 10 件因「文件方案在 70-160ms 短音上 ms 级抖动」 同理由与管线一致性裁决改 SYN(§修订记录 R1:SFX 全 SYN 兜底,prompt 转设计 参考);tune_seek/key_send 等节奏型音效时长以真实事件为准(§6.2 标注时长)。 用法:.venv-tts/bin/python tools/gen_cipher_syn.py """ import os import subprocess import sys import numpy as np import soundfile as sf ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg") OUT = os.path.join(ROOT, "tools", "gen_cipher_syn_wav") DST = os.path.join(ROOT, "web", "cipher", "audio", "sfx") os.makedirs(OUT, exist_ok=True) os.makedirs(DST, exist_ok=True) sr = 44100 ONE_SHOT_TP_CEIL = -3.0 # dBFS SAMPLE_PEAK = 0.66 # ≈ -3.6dBFS,给 MDCT 过冲留余量 FADE_N = int(sr * 0.008) _rng = np.random.default_rng(1940) 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 bell(n, f, partials=((1, 1.0), (2.4, 0.4), (3.9, 0.18), (5.4, 0.08)), dec=6.0): t = np.arange(n) / sr y = np.zeros(n) for r, g in partials: y += g * np.sin(2 * np.pi * f * r * t) * np.exp(-t * (dec / r)) return y def footstep(n_off, heavy=1.0): """皮靴脚步:55-90Hz 低频冲击 + 皮革摩擦带通噪声。""" tt = np.arange(n_off) / sr th = np.sin(2 * np.pi * (62 + _rng.uniform(-6, 6)) * tt) * np.exp(-tt * 22) * heavy th += bp_noise(n_off, 150, 700) * np.exp(-tt * 30) * 0.5 * heavy return th * (1 - np.exp(-tt * 300)) # ---------------------------------------------------------------- one-shot 渲染 def r_tune_seek(): # 调谐找频 3s:扫频静电 + 两处鬼影摩尔斯 + 落定静音点 n = int(sr * 3.0) t = np.arange(n) / sr y = bp_noise(n, 300, 4000) * 0.28 # 谐振峰在频段内来回扫(电台调谐手感) f0 = 900 + 1900 * np.sin(2 * np.pi * 0.55 * t) ** 3 ph = 2 * np.pi * np.cumsum(f0) / sr y += (np.sin(ph) + 0.4 * np.sin(2 * ph)) * 0.10 # 两处鬼影滴答(微弱 750Hz dit/dah,§2.1 电文音色同族,弱化远台) for st, pats in [(0.9, [1, 3, 1]), (2.0, [1, 1, 3])]: for i, u in enumerate(pats): L = int(sr * 0.08 * u) tt = np.arange(L) / sr mk = np.sin(2 * np.pi * 750 * tt) * np.exp(-tt * 6) * 0.16 place(y, st + i * 0.16, mk) env = np.minimum(1, t / 0.15) * np.minimum(1, (3.0 - t) / 0.3) return dc(y * env) def r_paper_page(): # 密码本翻页 0.5s:干纸摩擦(3-8kHz 噪声抖动)+ 指尖拍页轻响 n = int(sr * 0.50) y = bp_noise(n, 2600, 9000) * 0.5 t = np.arange(n) / sr flut = 0.5 + 0.5 * np.sin(2 * np.pi * 13 * t) * np.sin(2 * np.pi * 2.1 * t) env = (1 - np.exp(-t * 60)) * np.exp(-np.maximum(t - 0.28, 0) * 16) y *= flut * env tap_n = int(sr * 0.05) tt = np.arange(tap_n) / sr tap = bp_noise(tap_n, 900, 2600) * np.exp(-tt * 90) * 0.8 place(y, 0.30, tap) return dc(y) def r_key_send(): # 电键拍发 4s:快手七手法——不均匀节奏的黄铜键击串(句读有呼吸) n = int(sr * 4.0) y = np.zeros(n) # (起秒, 击长单位 u=80ms 倍数) —— 有连发有停顿的「手法」节奏 seq = [(0.20, 1), (0.36, 3), (0.68, 1), (0.84, 1), (1.16, 3), (1.48, 1), (1.80, 1), (2.12, 3), (2.44, 1), (2.60, 1), (2.92, 3), (3.24, 1), (3.40, 3)] for st, u in seq: L = int(sr * 0.08 * u) tt = np.arange(L) / sr click = bp_noise(L, 1400, 5200) * np.exp(-tt * 110) # 触点脆响 body = np.sin(2 * np.pi * 2100 * tt) * np.exp(-tt * 40) * 0.35 # 簧片余振 th = np.sin(2 * np.pi * 180 * tt) * np.exp(-tt * 80) * 0.25 # 底座轻顿 place(y, st, (click + body + th) * 0.8) return dc(y) def r_pen_scratch(): # 处长记事笔 1.5s:4 段 2.6kHz 带通沙写,顿笔收尾 n = int(sr * 1.50) y = np.zeros(n) for i, st in enumerate([0.05, 0.38, 0.72, 1.06]): L = int(sr * (0.20 + 0.05 * (i % 2))) tt = np.arange(L) / sr scr = bp_noise(L, 2200, 5600) * (0.55 + 0.45 * np.sin(2 * np.pi * 7 * tt)) scr *= (1 - np.exp(-tt * 250)) * np.exp(-np.maximum(tt - 0.14, 0) * 30) place(y, st, scr * 0.5) return dc(y) def r_tea_cup(): # 茶杯落桌 0.5s:瓷碰 2.8/3.7/4.6kHz 衰减 + 木桌低频轻顿 n = int(sr * 0.50) y = np.zeros(n) clink = bell(n, 2820, partials=((1, 1.0), (1.32, 0.5), (1.63, 0.3), (2.1, 0.12)), dec=28) tt = np.arange(n) / sr th = np.sin(2 * np.pi * 130 * tt) * np.exp(-tt * 45) * 0.5 return dc((clink * 0.5 + th) * (1 - np.exp(-tt * 900))) def r_step_halt(): # 门外脚步停住 3s(T4 danger 拍 L2 前置 2s 渐强):4 步逼近→第 4 步后死寂 n = int(sr * 3.0) y = np.zeros(n) t = np.arange(n) / sr for i, st in enumerate([0.10, 0.78, 1.46, 2.14]): L = int(sr * 0.22) y[int(st * sr):int(st * sr) + L] += footstep(L, heavy=0.75 + 0.22 * i) # 渐强窗:0→2s 缓慢抬头(L2 预警),脚步落停后房间音沉下去 env = np.minimum(1, 0.35 + 0.65 * np.clip((t - 0.4) / 1.8, 0, 1)) tail = bp_noise(n, 90, 400) * 0.05 * np.exp(-np.maximum(t - 2.3, 0) * 8) return dc((y + tail) * env) def r_lock_click(): # 门锁/枪机 1s:锁舌过槽两响 + 低频金属余振 n = int(sr * 1.0) y = np.zeros(n) for st, f, dec in [(0.06, 3400, 60), (0.22, 2400, 45)]: L = int(sr * 0.16) tt = np.arange(L) / sr c = bp_noise(L, 1200, 5600) * np.exp(-tt * dec) c += np.sin(2 * np.pi * f * tt) * np.exp(-tt * dec * 0.5) * 0.4 place(y, st, c) tt = np.arange(n) / sr y += np.sin(2 * np.pi * 95 * tt) * np.exp(-tt * 12) * 0.3 return dc(y) def r_dawn_bell(): # 黎明远钟 4s:低音钟(E3 系)远播 + 雨底残响;e1/e2 结算锚点 n = int(sr * 4.0) y = np.zeros(n) for st in [0.0, 1.9]: L = n - int(st * sr) tt = np.arange(L) / sr b = bell(L, 165, partials=((1, 1.0), (2.02, 0.42), (2.94, 0.22), (4.1, 0.08)), dec=3.2) place(y, st, b * 0.55) t = np.arange(n) / sr rain = bp_noise(n, 900, 6000) * 0.03 * (1 + 0.3 * np.sin(2 * np.pi * 0.3 * t)) return dc(y + rain * np.minimum(1, (4.0 - t) / 0.8)) def r_ui_confirm(): # 全局先例同款(storyline/radio/abyss gen_sfx.py:880->1320Hz 双音) a = np.sin(2 * np.pi * 880 * np.arange(int(sr * 0.09)) / sr) * np.exp(-np.arange(int(sr * 0.09)) / sr * 60) b = np.sin(2 * np.pi * 1320 * np.arange(int(sr * 0.14)) / sr) * np.exp(-np.arange(int(sr * 0.14)) / sr * 40) return dc(np.concatenate([a, np.zeros(int(sr * 0.02)), b, np.zeros(int(sr * 0.05))])) def r_ui_menu(): # 全局先例同款(330+495Hz 双谐波 0.35s) d = 0.35 x = np.arange(int(sr * d)) / sr return dc(np.concatenate([ (np.sin(2 * np.pi * 330 * x) + 0.5 * np.sin(2 * np.pi * 495 * x)) * np.exp(-x * 20), np.zeros(int(sr * 0.05))])) ONE_SHOTS = { "tune_seek": r_tune_seek, "paper_page": r_paper_page, "key_send": r_key_send, "pen_scratch": r_pen_scratch, "tea_cup": r_tea_cup, "step_halt": r_step_halt, "lock_click": r_lock_click, "dawn_bell": r_dawn_bell, "ui_confirm": r_ui_confirm, "ui_menu": r_ui_menu, } # ---------------------------------------------------------------- loop 渲染 def circular_smooth(y, k_s=0.05): """首尾 k 秒等功率交叉淡化(噪声类 loop 循环点去咔哒;相位类成分已网格化)。""" k = int(sr * k_s) w = np.sin(np.linspace(0, np.pi / 2, k)) ** 2 out = y[:-k].copy() out[:k] = out[:k] * w + y[-k:] * (1 - w) return out def r_static_bed(): # 短波静电底噪 4s 无缝:碎裂噪声 + 电源哼 60Hz 整周期 + 随机噼啪网格化 n = int(sr * 4.0) y = bp_noise(n, 250, 5200) * 0.13 t = np.arange(n) / sr y += np.sin(2 * np.pi * 60 * t) * 0.012 # 4s = 240 整周期,相位对齐 y += np.sin(2 * np.pi * 120 * t) * 0.006 # 噼啪:16 个落点整除网格,尾 30ms 不出界(n=4s 网格无需回卷) for i in range(16): st = i * 0.25 + _rng.uniform(0, 0.14) L = int(sr * 0.03) tt = np.arange(L) / sr pk = bp_noise(L, 1800, 8000) * np.exp(-tt * 130) * _rng.uniform(0.1, 0.3) place(y, st, pk) return dc(circular_smooth(dc(y))) def r_amb_room(): # 值班室夜境 8s 无缝:挂钟 8 tick(整 1Hz)+ 雨窗噪声 + 暖炉低哼 n = int(sr * 8.0) y = np.zeros(n) for i in range(8): # 每秒一记,尾 60ms 不出界 L = int(sr * 0.06) tt = np.arange(L) / sr tick = bp_noise(L, 2000, 7000) * np.exp(-tt * 160) * 0.5 tick += np.sin(2 * np.pi * 4200 * tt) * np.exp(-tt * 200) * 0.25 place(y, i * 1.0, tick) t = np.arange(n) / sr rain = bp_noise(n, 700, 4500) * 0.05 * (0.6 + 0.4 * np.sin(2 * np.pi * 0.25 * t) ** 2) # 0.25Hz 整 2 周期 hum = np.sin(2 * np.pi * 100 * t) * 0.012 + np.sin(2 * np.pi * 150 * t) * 0.006 # 整周期 return dc(circular_smooth(dc(y + rain + hum))) LOOPS = { "static_bed": r_static_bed, "amb_room": r_amb_room, } # ---------------------------------------------------------------- 编码 + 真峰值校验 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}") for fid, fn in LOOPS.items(): wav_path = os.path.join(OUT, fid + ".wav") base = os.path.join(DST, fid) y = norm(fn()) # loop:去直流已在渲染内;无首尾淡化(无缝优先) sf.write(wav_path, stereo(y), sr) seam = abs(float(y[0]) - float(y[-1])) encode(wav_path, base) tp = check_tp(base) report.append((fid, len(y) / sr, tp, f"loop seam |y0-y1|={seam:.4f}")) if tp > ONE_SHOT_TP_CEIL: problems.append(f"{fid}: loop 真峰值 {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 + {len(LOOPS)} loop -> {DST}") return 0 if __name__ == "__main__": sys.exit(main())