Files
SoundGame/tools/gen_cipher_syn.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

350 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 -*-
"""《暗码》程序合成音效(19-cipher.md §6.2,12 件 SYN 全部程序合成兜底)。
落盘:staging wav → tools/gen_cipher_syn_wav/<id>.wav(44.1kHz 立体声);
成品 → web/cipher/audio/sfx/<id>.{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())