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(压缩候选)
This commit is contained in:
xsl
2026-09-14 05:48:13 +08:00
parent c8843233b0
commit 4b39ae2e49
2676 changed files with 62789 additions and 1282 deletions
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""《深夜茶馆》Stable Audio 3 small-sfx 音效(20-teahouse.md §8.2 SA3 条目,5 件)。
生成(GPU,flock 串行;模型已入缓存,HF_HUB_OFFLINE 可跑,见 env.d/sfx.sh):
cd tools/stable-audio-3 && flock /home/xsl/SoundGame/.gpu.lock \
.venv/bin/stable-audio --model small-sfx --steps 8 --seed 20 \
-p "<prompts>" --duration <secs> -o <staging>/teahouse.wav
本脚本随后做后处理:自然修剪/去直流/8ms cos² 淡化/响度定档 → ogg q3 + m4a 128k
→ 解码回读 QC:真峰值 ≤ -3dBFS、|直流| ≤ 0.001;超限降增益 + 重编,最多 3 轮。
prompt:20-teahouse.md §8.2 条目英文化。定档(§3.3 频带分离 + 操作音不抢人声):
door_bell -8(铜铃两响,进门标记)、steps_in/out -12(脚步远低近高,床级)
water_pour -8(注水 2s 主操作音)、pour_cup -8(上茶落盏)
用法:.venv-tts/bin/python web/teahouse/gen/gen_sfx_sa3.py [--emit]
"""
import os
import subprocess
import sys
import numpy as np
import soundfile as sf
SR = 44100
HERE = os.path.dirname(os.path.abspath(__file__))
STAGING = os.path.join(HERE, "staging", "sfx_sa3")
DST = os.path.join(HERE, "..", "audio", "sfx")
SA3 = "/home/xsl/SoundGame/tools/stable-audio-3"
FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg"
SEED = 20
os.makedirs(STAGING, exist_ok=True)
os.makedirs(DST, exist_ok=True)
# (id, gen 秒, prompt, 定档 dBFS, trim 上限 s)
ITEMS = [
("door_bell", 4.0, "Small brass shop door bell rings twice, warm clear chime, quiet night tea house, close", -8.0, 1.2),
("steps_in", 6.0, "Soft cloth shoes walking on wooden floor, slow approach from far to near, gentle steps, quiet room, close", -12.0, 3.5),
("steps_out", 6.0, "Soft cloth shoes walking on wooden floor, slow walk away from near to far, gentle steps fading, quiet room, close", -12.0, 3.5),
("water_pour", 4.0, "Water pouring into a metal kettle, steady pour, two seconds, close, clean recording", -8.0, 2.6),
("pour_cup", 4.0, "Hot tea poured into a small ceramic cup, gentle pour ending with cup placed on saucer, close", -8.0, 2.6),
]
WIN = {}
def emit():
durs = " ".join(str(d) for _, d, *_ in ITEMS)
ps = " ".join(f'"{p}"' for _, _, p, *_ in ITEMS)
print(f"cd {SA3} && flock /home/xsl/SoundGame/.gpu.lock .venv/bin/stable-audio "
f"--model small-sfx --steps 8 --seed {SEED} "
f"-p {ps} --duration {durs} -o {STAGING}/teahouse.wav")
def trim_oneshot(y, floor_db=-50.0, head_ms=50, tail_ms=140, max_s=None):
env = np.max(np.abs(y), axis=1)
act = np.where(env > 10 ** (floor_db / 20))[0]
a = max(0, act[0] - int(SR * head_ms / 1000))
b = min(len(y), act[-1] + int(SR * tail_ms / 1000))
if max_s:
b = min(b, a + int(SR * max_s))
return y[a:b]
def prep_oneshot(y, target_db, win=None, max_s=None):
if win:
a, b = int(SR * win[0]), int(SR * win[1])
y = y[a:b]
nf2 = max(1, int(SR * 0.050))
y[-nf2:] *= (0.5 * (1 + np.cos(np.pi * np.arange(nf2) / nf2)))[:, None]
else:
y = trim_oneshot(y, max_s=max_s)
y = y - np.mean(y, axis=0, keepdims=True)
nf = max(1, int(SR * 0.008))
fade = 0.5 * (1 - np.cos(np.pi * np.arange(nf) / nf))
y[:nf] *= fade[:, None]
y[-nf:] *= fade[::-1][:, None]
y = y / (np.max(np.abs(y)) + 1e-9) * 10 ** (target_db / 20)
pad_h = np.zeros((int(SR * 0.06), y.shape[1]))
pad_t = np.zeros((int(SR * 0.12), y.shape[1]))
return np.concatenate([pad_h, y, pad_t], axis=0)
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():
# stable-audio CLI 多 prompt 输出 <out>_<idx>.wav(每 prompt 一支)
if not os.path.exists(os.path.join(STAGING, "teahouse_0.wav")):
emit()
sys.exit("请先执行 --emit 打印的命令生成 raw wav 后重跑本脚本")
report = []
problems = []
for i, (fid, _d, _p, db, max_s) in enumerate(ITEMS):
y, sr = sf.read(os.path.join(STAGING, f"teahouse_{i}.wav"))
assert sr == SR, f"sr {sr}" if False else None
wav_path = os.path.join(STAGING, fid + ".wav")
base = os.path.join(DST, fid)
out = prep_oneshot(y, db, win=WIN.get(fid), max_s=max_s)
sf.write(wav_path, out, SR)
encode(wav_path, base)
tp = check_tp(base)
for _round in range(3):
if tp <= -3.0:
break
gain = 10 ** ((-3.0 - tp) / 20) * 0.98
out = out * gain
sf.write(wav_path, out, SR)
encode(wav_path, base)
tp = check_tp(base)
dc_val = float(np.max(np.abs(np.mean(out, axis=0))))
report.append((fid, len(out) / SR, tp, dc_val))
if tp > -3.0:
problems.append(f"{fid}: 真峰值 {tp:.2f} 超 -3dBFS")
if dc_val > 0.001:
problems.append(f"{fid}: 直流 {dc_val:.5f}")
print(f"{'id':12s} {'dur(s)':>7s} {'TP(dBFS)':>9s} {'|dc|':>8s}")
for fid, dur, tp, dcv in report:
print(f"{fid:12s} {dur:7.2f} {tp:9.2f} {dcv:8.5f}")
if problems:
print("PROBLEMS:")
for p in problems:
print(" ", p)
return 1
print(f"ALL OK: {len(ITEMS)} SA3 -> {DST}")
return 0
if __name__ == "__main__":
if "--emit" in sys.argv:
emit()
sys.exit(0)
sys.exit(main())