Files
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

150 lines
6.0 KiB
Python
Raw Permalink 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 -*-
"""《深夜茶馆》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())