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:
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# 《深夜茶馆》BGM 后处理:Mureka flac 候选 -> 90s 无缝循环 ogg+m4a。
|
||||
# 用法: bash web/teahouse/gen/gen_bgm.sh (flac 原件在 tools/mureka_out/teahouse-*/,不入库)
|
||||
#
|
||||
# 工艺(tools/gen_hunt_bgm.sh 同款,tools/mureka_log.md 先例):
|
||||
# 首个强 onset 起点(silenceremove,-35dB 门限)→ 93s 裁剪 → 首尾 3s acrossfade(tri)
|
||||
# 无缝循环 → loudnorm I=-20/TP=-2/LRA=11(20-teahouse.md §8.1 三首 -20 LUFS;
|
||||
# CLOSE 结算 0.5 volume 由游戏层控制)→ aresample 44.1kHz
|
||||
# → ogg libvorbis q3 + m4a aac 128k,落 web/teahouse/audio/bgm/。
|
||||
# 本作无节拍网格玩法(时机判定绑声学事件窗非乐句网格),不跑 bpm_precise。
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../../.."
|
||||
export PATH="$PWD/tools/bin:$PATH"
|
||||
|
||||
TMP=tools/bgm_post_wav/teahouse # 中间 wav(不入库,可由本脚本复现)
|
||||
DST=web/teahouse/audio/bgm
|
||||
mkdir -p "$TMP" "$DST"
|
||||
|
||||
post() { # post <flac> <track> <LUFS>
|
||||
local src="$1" track="$2" lufs="$3"
|
||||
local tmp="$TMP/$track.wav"
|
||||
ffmpeg -y -v error -i "$src" -af \
|
||||
"silenceremove=start_periods=1:start_threshold=-35dB:start_silence=0.02,atrim=end=93,asetpts=PTS-STARTPTS,asplit[a][b];[b]atrim=start=90,asetpts=PTS-STARTPTS[t];[a][t]acrossfade=d=3:c1=tri:c2=tri,loudnorm=I=${lufs}:TP=-2:LRA=11,aresample=44100" \
|
||||
"$tmp"
|
||||
ffmpeg -y -v error -i "$tmp" -c:a libvorbis -q:a 3 "$DST/$track.ogg"
|
||||
ffmpeg -y -v error -i "$tmp" -c:a aac -b:a 128k "$DST/$track.m4a"
|
||||
echo "$track <- $src (I=${lufs})"
|
||||
}
|
||||
|
||||
# 选用候选(每任务 2 候选取更接近 90s 的一支;task id 见 tools/mureka_log.md teahouse 节)
|
||||
post tools/mureka_out/teahouse-menu/160730009567233_1.flac menu -20
|
||||
post tools/mureka_out/teahouse-serve/160730234093569_1.flac serve -20
|
||||
post tools/mureka_out/teahouse-brew/160730376765441_1.flac brew -20
|
||||
|
||||
echo "== teahouse bgm done: $(ls "$DST" | wc -l) files =="
|
||||
@@ -0,0 +1,92 @@
|
||||
"""深夜茶馆 cover.png 生成:1080x1920,深巷夜色底 + 灯笼暖光 + 茶烟 + 游戏名 + 玩法提示。"""
|
||||
import math
|
||||
import random
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageChops
|
||||
|
||||
W, H = 1080, 1920
|
||||
FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"
|
||||
OUT = "/home/xsl/AutoVideo/SoundGame/web/teahouse/cover.png"
|
||||
|
||||
random.seed(20)
|
||||
|
||||
img = Image.new("RGB", (W, H))
|
||||
px = img.load()
|
||||
# 深巷夜色渐变:上 #0a0d16 -> 下 #1c130a(底部灯笼映暖)
|
||||
top, bottom = (10, 13, 22), (28, 19, 10)
|
||||
for y in range(H):
|
||||
t = y / (H - 1)
|
||||
for x in range(W):
|
||||
px[x, y] = tuple(int(a + (b - a) * t) for a, b in zip(top, bottom))
|
||||
|
||||
d = ImageDraw.Draw(img)
|
||||
# 星点(稀疏,都市夜空)
|
||||
for _ in range(140):
|
||||
x, y = random.randint(0, W - 1), random.randint(0, int(H * 0.4))
|
||||
c = random.randint(90, 180)
|
||||
d.point((x, y), fill=(c, c, min(255, c + 10)))
|
||||
|
||||
# 两侧屋檐剪影(巷子纵深)
|
||||
d.polygon([(0, 0), (W * 0.30, 0), (W * 0.16, H * 0.34), (0, H * 0.42)], fill=(6, 7, 12))
|
||||
d.polygon([(W, 0), (W * 0.70, 0), (W * 0.84, H * 0.34), (W, H * 0.42)], fill=(6, 7, 12))
|
||||
|
||||
# 巷子尽头茶馆门脸亮窗(暖光小方块)
|
||||
d.rectangle([W * 0.42, H * 0.40, W * 0.58, H * 0.47], fill=(64, 42, 18))
|
||||
d.rectangle([W * 0.44, H * 0.415, W * 0.56, H * 0.455], fill=(120, 78, 30))
|
||||
|
||||
# 主灯笼(中上,暖橙光晕两层)
|
||||
cx, cy = W / 2, H * 0.30
|
||||
glow = Image.new("RGB", (W, H), (0, 0, 0))
|
||||
gg = ImageDraw.Draw(glow)
|
||||
gg.ellipse([cx - W * 0.26, cy - W * 0.26, cx + W * 0.26, cy + W * 0.26], fill=(52, 30, 10))
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(60))
|
||||
img = ImageChops.add(img, glow)
|
||||
d = ImageDraw.Draw(img)
|
||||
# 灯笼体:椭圆 + 骨架线
|
||||
d.ellipse([cx - W * 0.115, cy - H * 0.052, cx + W * 0.115, cy + H * 0.052], fill=(196, 84, 32))
|
||||
for k in range(-3, 4):
|
||||
xk = cx + k * W * 0.028
|
||||
d.arc([xk - W * 0.10, cy - H * 0.052, xk + W * 0.10, cy + H * 0.052],
|
||||
start=300 if k < 0 else 240, end=420 if k > 0 else 60, fill=(140, 56, 20), width=3)
|
||||
d.rectangle([cx - W * 0.03, cy - H * 0.062, cx + W * 0.03, cy - H * 0.050], fill=(120, 96, 40))
|
||||
d.rectangle([cx - W * 0.03, cy + H * 0.050, cx + W * 0.03, cy + H * 0.062], fill=(120, 96, 40))
|
||||
# 灯穗
|
||||
d.line([cx, cy + H * 0.062, cx, cy + H * 0.095], fill=(160, 40, 30), width=6)
|
||||
|
||||
# 茶烟(灯笼上方,淡灰曲线)
|
||||
smoke = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
sd = ImageDraw.Draw(smoke)
|
||||
for i in range(3):
|
||||
pts = []
|
||||
for t in range(60):
|
||||
u = t / 59
|
||||
x = cx + math.sin(u * 6 + i * 2) * 34 * (1 - u * 0.4) + (i - 1) * 26
|
||||
y = cy - H * 0.075 - u * H * 0.16
|
||||
pts.append((x, y))
|
||||
sd.line(pts, fill=(180, 180, 190, 46), width=10)
|
||||
smoke = smoke.filter(ImageFilter.GaussianBlur(6))
|
||||
img.paste(Image.alpha_composite(img.convert("RGBA"), smoke).convert("RGB"), (0, 0))
|
||||
|
||||
# 底部茶盏剪影 + 涟漪声纹环(声音主题)
|
||||
d = ImageDraw.Draw(img)
|
||||
bcx, bcy = W / 2, H * 0.74
|
||||
d.ellipse([bcx - 130, bcy - 40, bcx + 130, bcy + 60], fill=(14, 10, 8))
|
||||
d.ellipse([bcx - 108, bcy - 28, bcx + 108, bcy + 34], fill=(46, 30, 14))
|
||||
for r, a in [(230, 60), (300, 40), (370, 24)]:
|
||||
d.arc([bcx - r, bcy - r * 0.36, bcx + r, bcy + r * 0.36], start=200, end=340,
|
||||
fill=(90, 66, 34), width=4)
|
||||
|
||||
# 文字
|
||||
from PIL import ImageFont
|
||||
def font(sz):
|
||||
return ImageFont.truetype(FONT, sz)
|
||||
|
||||
d.text((W / 2, H * 0.56), "深夜茶馆", font=font(120), fill=(240, 226, 200), anchor="mm")
|
||||
d.text((W / 2, H * 0.615), "茶是热的,夜是长的", font=font(40), fill=(200, 178, 140), anchor="mm")
|
||||
d.text((W / 2, H * 0.885), "左右滑选茶 · 双击提壶出汤 · 上滑状态 · 下滑菜单",
|
||||
font=font(34), fill=(170, 158, 138), anchor="mm")
|
||||
d.text((W / 2, H * 0.925), "纯音频手势游戏 · 戴上耳机更佳",
|
||||
font=font(30), fill=(130, 122, 108), anchor="mm")
|
||||
|
||||
img.save(OUT)
|
||||
print("saved", OUT)
|
||||
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《深夜茶馆》程序合成音效(20-teahouse.md §8.2,24 件 SYN;SA3 5 件见 gen_sfx_sa3.py)。
|
||||
|
||||
落盘:staging wav → web/teahouse/gen/staging/syn/<id>.wav(44.1kHz 立体声);
|
||||
成品 → web/teahouse/audio/sfx/<id>.{ogg,m4a}(tools/bin/ffmpeg 双格式)。
|
||||
|
||||
one-shot QC:去直流 + 首尾 8ms cos² 淡化 + 编码后解码回读真峰值 ≤ -3dBFS
|
||||
(MDCT 过冲坑:以 ebur128 peak=true 实测为准,超限自动降档重编)。
|
||||
本作无 loop sfx(候汤/闷泡时间线 = one-shot 链 + setTimeout 调度,§3.2)。
|
||||
|
||||
频带分离(§3.3):沸腾家族低频 150-500Hz / 滴答木鱼 ~880Hz 高频 /
|
||||
语音中频——偶发瞬时叠听不糊。
|
||||
|
||||
用法:.venv-tts/bin/python web/teahouse/gen/gen_sfx.py
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
SR = 44100
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "staging", "syn")
|
||||
DST = os.path.join(HERE, "..", "audio", "sfx")
|
||||
FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
os.makedirs(DST, exist_ok=True)
|
||||
|
||||
ONE_SHOT_TP_CEIL = -3.0
|
||||
SAMPLE_PEAK = 0.66
|
||||
FADE_N = int(SR * 0.008)
|
||||
_rng = np.random.default_rng(20)
|
||||
|
||||
|
||||
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 sine_track(n, f0, f1, amp=1.0, vib_hz=0.0, vib_depth=0.0):
|
||||
t = np.arange(n) / SR
|
||||
f = np.linspace(f0, f1, n)
|
||||
if vib_hz:
|
||||
f = f * (1 + vib_depth * np.sin(2 * np.pi * vib_hz * t))
|
||||
ph = 2 * np.pi * np.cumsum(f) / SR
|
||||
return amp * np.sin(ph)
|
||||
|
||||
|
||||
def knock(n, f, decay, bright=0.3):
|
||||
"""木/陶质敲击:基频 + 非谐波泛音簇,快起指数衰。"""
|
||||
t = np.arange(n) / SR
|
||||
y = np.sin(2 * np.pi * f * t)
|
||||
for r, g in [(2.71, bright), (4.95, bright * 0.5), (7.9, bright * 0.25)]:
|
||||
y += g * np.sin(2 * np.pi * f * r * t)
|
||||
return y * (1 - np.exp(-t * 500)) * np.exp(-t * decay)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 渲染
|
||||
def r_sig_zhou(): # 扳手磕搪瓷缸:金属亮击 + 短铃振
|
||||
n = int(SR * 0.45)
|
||||
t = np.arange(n) / SR
|
||||
y = knock(n, 1240, 22, bright=0.9) * 0.8
|
||||
y += knock(n, 2870, 30, bright=0.4) * 0.35
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_sig_su(): # 指尖叩桌面两下:两声闷木叩,第二声略轻
|
||||
n = int(SR * 0.50)
|
||||
y = np.zeros(n)
|
||||
for st, g in [(0.0, 1.0), (0.22, 0.7)]:
|
||||
seg_n = n - int(st * SR)
|
||||
place(y, st, knock(seg_n, 340, 40, bright=0.2) * g)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_sig_shi(): # 拉链+书本落桌:金属擦 + 闷拍
|
||||
n = int(SR * 0.60)
|
||||
t = np.arange(n) / SR
|
||||
zip_n = int(SR * 0.22)
|
||||
z = bp_noise(zip_n, 2500, 6500) * np.linspace(0.3, 1.0, zip_n) * np.exp(-np.arange(zip_n) / SR * 8)
|
||||
y = np.zeros(n)
|
||||
place(y, 0.0, z * 0.5)
|
||||
book_n = n - int(0.26 * SR)
|
||||
bt = np.arange(book_n) / SR
|
||||
book = np.sin(2 * np.pi * 95 * bt) * np.exp(-bt * 35)
|
||||
book += bp_noise(book_n, 120, 500) * np.exp(-bt * 40) * 0.9
|
||||
place(y, 0.26, book * 0.9)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_sig_bai(): # 棋子落木盘:轻脆单响(高频木盘)
|
||||
n = int(SR * 0.30)
|
||||
return dc(knock(n, 1750, 35, bright=0.6))
|
||||
|
||||
|
||||
def r_sig_he(): # 车钥匙串抖两下:两簇高频金属哗啦
|
||||
n = int(SR * 0.55)
|
||||
y = np.zeros(n)
|
||||
for st, g in [(0.0, 1.0), (0.20, 0.75)]:
|
||||
seg_n = min(int(SR * 0.16), n - int(st * SR))
|
||||
seg_t = np.arange(seg_n) / SR
|
||||
j = np.zeros(seg_n)
|
||||
for f in [4200, 5300, 6600]:
|
||||
j += np.sin(2 * np.pi * f * seg_t) * np.exp(-seg_t * (45 + f / 400))
|
||||
j += bp_noise(seg_n, 3000, 8000) * np.exp(-seg_t * 30) * 0.5
|
||||
place(y, st, j * g)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_tea_jar(): # 木罐启盖 + 茶叶沙沙:木旋 + 干沙噪声
|
||||
n = int(SR * 0.35)
|
||||
t = np.arange(n) / SR
|
||||
y = knock(n, 520, 28, bright=0.3) * 0.7
|
||||
leaf_n = n - int(0.10 * SR)
|
||||
lt = np.arange(leaf_n) / SR
|
||||
y[int(0.10 * SR):] += bp_noise(leaf_n, 1800, 5200) * np.exp(-lt * 18) * 0.35
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_tea_scoop(): # 铜勺入壶:金属轻磕 + 细沙落
|
||||
n = int(SR * 0.35)
|
||||
t = np.arange(n) / SR
|
||||
y = knock(n, 1980, 40, bright=0.7) * 0.7
|
||||
leaf_n = n - int(0.08 * SR)
|
||||
lt = np.arange(leaf_n) / SR
|
||||
y[int(0.08 * SR):] += bp_noise(leaf_n, 2400, 6000) * np.exp(-lt * 25) * 0.3
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_boil_blip(): # 单泡音 0.3s:低频咕嘟一枚(150-500Hz 带)
|
||||
n = int(SR * 0.30)
|
||||
t = np.arange(n) / SR
|
||||
f = 320 + 60 * np.sin(np.pi * t / 0.28)
|
||||
plip = sine_track(n, 300, 380) * np.exp(-((t - 0.09) / 0.08) ** 2)
|
||||
plip += bp_noise(n, 200, 700) * np.exp(-((t - 0.10) / 0.09) ** 2) * 0.8
|
||||
return dc(plip)
|
||||
|
||||
|
||||
def r_boil_simmer(): # 低鸣 one-shot 1.6s:密集小泡低鸣(将沸段床)
|
||||
n = int(SR * 1.60)
|
||||
t = np.arange(n) / SR
|
||||
y = bp_noise(n, 150, 600)
|
||||
# 泡破裂随机调制
|
||||
am = 0.6 + 0.4 * np.sin(2 * np.pi * 7 * t) * np.sin(2 * np.pi * 1.3 * t)
|
||||
env = (1 - np.exp(-t * 8)) * np.exp(-np.maximum(t - 1.2, 0) * 4)
|
||||
rum = sine_track(n, 110, 120, 0.35)
|
||||
return dc(y * am * env * 0.8 + rum * env)
|
||||
|
||||
|
||||
def r_boil_roar(): # 连片滚沸 2.0s/段:粗重咕嘟(滚透段床)
|
||||
n = int(SR * 2.00)
|
||||
t = np.arange(n) / SR
|
||||
y = bp_noise(n, 150, 500) * (0.7 + 0.3 * np.sin(2 * np.pi * 5.5 * t))
|
||||
rum = sine_track(n, 90, 100, 0.5)
|
||||
env = (1 - np.exp(-t * 6)) * np.exp(-np.maximum(t - 1.7, 0) * 5)
|
||||
# 大泡破裂点缀
|
||||
bub = np.zeros(n)
|
||||
for st in [0.35, 0.9, 1.45]:
|
||||
seg_n = n - int(st * SR)
|
||||
seg_t = np.arange(seg_n) / SR
|
||||
place(bub, st, (sine_track(seg_n, 240, 160) + bp_noise(seg_n, 200, 600) * 0.8)
|
||||
* np.exp(-seg_t * 22) * 0.9)
|
||||
return dc((y * 0.8 + rum) * env + bub)
|
||||
|
||||
|
||||
def r_boil_over(): # 滚老剧烈嘶鸣 1.2s:高频汽哨 + 溢出沸溅
|
||||
n = int(SR * 1.20)
|
||||
t = np.arange(n) / SR
|
||||
hiss = bp_noise(n, 1200, 4200) * (1 - np.exp(-t * 30))
|
||||
sput = bp_noise(n, 300, 900) * (0.5 + 0.5 * np.sin(2 * np.pi * 9 * t))
|
||||
y = hiss * 0.8 + sput * 0.5
|
||||
env = np.exp(-np.maximum(t - 0.85, 0) * 6)
|
||||
return dc(y * env)
|
||||
|
||||
|
||||
def r_kettle_lift(): # 提壶离火蒸汽「呲」0.5s
|
||||
n = int(SR * 0.50)
|
||||
t = np.arange(n) / SR
|
||||
y = bp_noise(n, 2000, 5000) * (1 - np.exp(-t * 60)) * np.exp(-t * 7)
|
||||
y += sine_track(n, 2600, 2400, 0.15) * np.exp(-t * 6)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_steep_rustle(): # 干茶舒展沙沙 2.0s(低响度)
|
||||
n = int(SR * 2.00)
|
||||
t = np.arange(n) / SR
|
||||
y = bp_noise(n, 900, 3600) * (0.5 + 0.5 * np.sin(2 * np.pi * 0.8 * t))
|
||||
env = (1 - np.exp(-t * 4)) * np.exp(-np.maximum(t - 1.6, 0) * 5)
|
||||
return dc(y * env * 0.45)
|
||||
|
||||
|
||||
def r_tick_slow(): # 木鱼滴答(疏)0.15s:880Hz 族轻响
|
||||
n = int(SR * 0.15)
|
||||
return dc(knock(n, 880, 55, bright=0.5) * 0.8)
|
||||
|
||||
|
||||
def r_tick_fast(): # 木鱼滴答(密)0.12s:880Hz 族,更脆
|
||||
n = int(SR * 0.12)
|
||||
return dc(knock(n, 990, 70, bright=0.6) * 0.8)
|
||||
|
||||
|
||||
def r_coin_soft(): # 单枚铜钱落桌:脆金属点 + 微滚
|
||||
n = int(SR * 0.35)
|
||||
t = np.arange(n) / SR
|
||||
y = knock(n, 4650, 60, bright=0.8) * 0.8
|
||||
y += knock(n, 5900, 80, bright=0.3) * 0.25
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_coin_stack(): # 三枚铜钱落桌:三连脆响渐轻
|
||||
n = int(SR * 0.60)
|
||||
y = np.zeros(n)
|
||||
for st, g in [(0.0, 1.0), (0.13, 0.8), (0.26, 0.65)]:
|
||||
seg_n = n - int(st * SR)
|
||||
seg_t = np.arange(seg_n) / SR
|
||||
c = knock(seg_n, 4400 + int(st * 800), 55, bright=0.7) * g
|
||||
c += bp_noise(seg_n, 3000, 7000) * np.exp(-seg_t * 60) * 0.3 * g
|
||||
place(y, st, c)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_rep_chime(): # 口碑上行双音:上行两铃(E5→G5)
|
||||
n = int(SR * 0.55)
|
||||
y = np.zeros(n)
|
||||
for st, f in [(0.0, 659.25), (0.16, 783.99)]:
|
||||
seg_n = n - int(st * SR)
|
||||
seg_t = np.arange(seg_n) / SR
|
||||
bell = sum(g * np.sin(2 * np.pi * f * r * seg_t) * np.exp(-seg_t * d)
|
||||
for r, g, d in [(1, 1.0, 9), (2.76, 0.22, 14)])
|
||||
place(y, st, bell * 0.55)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_rep_fall(): # 口碑下行双音:下行两铃(E5→C5)
|
||||
n = int(SR * 0.55)
|
||||
y = np.zeros(n)
|
||||
for st, f in [(0.0, 659.25), (0.16, 523.25)]:
|
||||
seg_n = n - int(st * SR)
|
||||
seg_t = np.arange(seg_n) / SR
|
||||
bell = sum(g * np.sin(2 * np.pi * f * r * seg_t) * np.exp(-seg_t * d)
|
||||
for r, g, d in [(1, 1.0, 9), (2.76, 0.22, 14)])
|
||||
place(y, st, bell * 0.55)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_lamp_light(): # 开灯「嗒」:拉绳开关脆响
|
||||
n = int(SR * 0.25)
|
||||
t = np.arange(n) / SR
|
||||
y = knock(n, 2100, 90, bright=0.5) * 0.8
|
||||
y += bp_noise(n, 800, 2500) * np.exp(-t * 90) * 0.4
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_lamp_off(): # 熄灯:轻「嗒」+ 余韵下沉
|
||||
n = int(SR * 0.45)
|
||||
t = np.arange(n) / SR
|
||||
y = knock(n, 1500, 70, bright=0.4) * 0.7
|
||||
y += sine_track(n, 320, 90, 0.2) * np.exp(-t * 8)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_shop_buy(): # 置办:算盘珠一拨 + 钱柜抽屉
|
||||
n = int(SR * 0.80)
|
||||
y = np.zeros(n)
|
||||
for st in [0.0, 0.09, 0.18]: # 算盘三下
|
||||
seg_n = n - int(st * SR)
|
||||
place(y, st, knock(seg_n, 2600, 80, bright=0.5) * 0.5)
|
||||
draw_n = n - int(0.34 * SR)
|
||||
dt = np.arange(draw_n) / SR
|
||||
draw = bp_noise(draw_n, 200, 700) * np.minimum(dt * 20, 1) * (0.6 + 0.4 * np.sin(2 * np.pi * 13 * dt))
|
||||
place(y, 0.34, draw * 0.7)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_upg_install(): # 木工轻敲 ×3
|
||||
n = int(SR * 0.60)
|
||||
y = np.zeros(n)
|
||||
for st, g in [(0.0, 1.0), (0.22, 0.85), (0.44, 0.7)]:
|
||||
seg_n = n - int(st * SR)
|
||||
place(y, st, knock(seg_n, 700, 45, bright=0.35) * g)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_turn_tick(): # 看门狗 L1 木鱼轻响(880Hz 族,dungeon 配方)
|
||||
n = int(SR * 0.12)
|
||||
return dc(knock(n, 880, 60, bright=0.4) * 0.6)
|
||||
|
||||
|
||||
ONE_SHOTS = {
|
||||
"sig_zhou": r_sig_zhou,
|
||||
"sig_su": r_sig_su,
|
||||
"sig_shi": r_sig_shi,
|
||||
"sig_bai": r_sig_bai,
|
||||
"sig_he": r_sig_he,
|
||||
"tea_jar": r_tea_jar,
|
||||
"tea_scoop": r_tea_scoop,
|
||||
"boil_blip": r_boil_blip,
|
||||
"boil_simmer": r_boil_simmer,
|
||||
"boil_roar": r_boil_roar,
|
||||
"boil_over": r_boil_over,
|
||||
"kettle_lift": r_kettle_lift,
|
||||
"steep_rustle": r_steep_rustle,
|
||||
"tick_slow": r_tick_slow,
|
||||
"tick_fast": r_tick_fast,
|
||||
"coin_soft": r_coin_soft,
|
||||
"coin_stack": r_coin_stack,
|
||||
"rep_chime": r_rep_chime,
|
||||
"rep_fall": r_rep_fall,
|
||||
"lamp_light": r_lamp_light,
|
||||
"lamp_off": r_lamp_off,
|
||||
"shop_buy": r_shop_buy,
|
||||
"upg_install": r_upg_install,
|
||||
"turn_tick": r_turn_tick,
|
||||
}
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
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 -> {DST}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user