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