feat(new8/batch2): 《深海回音》abyss +《雨夜专案》detective 全套交付(多agent流水线:策划→资产→开发→测试→真资产复跑)
- abyss:game.js 242 断言全绿(剧本A-F六路径/氧气门三形态/sonarGate仲裁/结算死窗/双轨看门狗/radio 8项修复继承);资产 148句+11key变体(电台滤波带内能量47-62%→69-80%) - detective:game.js 265 断言真资产下全绿(矩阵不变式独立重算/指证全路径/评级12组注入/VERDICT死窗/二周目变体);真资产复跑回炉3处实现修复(无进展轨墙钟化/菜单暂停记账/停止播报真停声——mock时长掩盖的正确性bug) - 资产:TTS 539条×2格式、Mureka 7首 $0.21(累计$1.08/$2.00)、SYN12+SA3 19件(QC抓出1例AAC过冲并修复)、封面×2、check_all_assets 10游戏全绿(abyss 298/298、detective 279/279) - 平台接线:大厅双卡片、BUCKETS 增 abyss/detective 桶(H4 断言更新 10 游戏)、voice_text.json 10游戏991句 - 回归:14 套件全绿(10 playtest+guide+engine-voice+engine-input+api-smoke 12/12) - 设计偏差登记:abyss 7条/detective 9条(全为设计内部冲突按承诺清单/归因真话律取优)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# 《雨夜专案》BGM 后处理(批次 2):Mureka flac 候选 -> 90s 无缝循环 ogg+m4a。
|
||||
# 用法: bash web/detective/gen/gen_bgm.sh (flac 原件在 tools/mureka_out/,不入库)
|
||||
#
|
||||
# 工艺(tools/mureka_log.md 批次 1 同款):
|
||||
# 首个强 onset 起点(silenceremove,-35dB 门限)→ 93s 裁剪 → 首尾 3s acrossfade(tri)
|
||||
# 无缝循环 → loudnorm I=-20/TP=-2/LRA=11(18-detective.md §8.1 明确 3 首全部 -20 LUFS;
|
||||
# SETTLE 的 0.5 volume 由游戏层控制)→ aresample 44.1kHz
|
||||
# → ogg libvorbis q3 + m4a aac 128k,落 web/detective/audio/bgm/。
|
||||
# 雨声底按设计烤进曲目(§8.1:省第三声流);无节奏关键曲目,不需要 bpm_precise
|
||||
# (jazz 的 brushed drums 仅为 prompt 特征词)。
|
||||
set -euo pipefail
|
||||
cd /home/xsl/SoundGame
|
||||
|
||||
TMP=tools/bgm_post_wav/detective # 中间 wav(不入库,可由本脚本复现)
|
||||
DST=web/detective/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})"
|
||||
}
|
||||
|
||||
# 选用候选 = 更接近 90s 的那支(拿不准原则),详见 mureka_log.md 批次 2 节
|
||||
post tools/mureka_out/detective-menu/160001918435329_1.flac menu -20 # 219.8s
|
||||
post tools/mureka_out/detective-board/160002065039361_0.flac board -20 # 216.8s
|
||||
post tools/mureka_out/detective-verdict/160002209873922_1.flac verdict -20 # 223.7s
|
||||
|
||||
echo "== detective bgm done: $(ls "$DST" | wc -l) files =="
|
||||
@@ -0,0 +1,115 @@
|
||||
"""web/detective 封面:1080x1920,雨夜推理主题——墨蓝夜雨、一盏路灯暖光、
|
||||
雨丝竖线与居中标题《雨夜专案》。可复现脚本(固定 seed,无外部网络依赖)。
|
||||
|
||||
用法(PIL 探测顺序:系统 python3 → .venv-tts → 装 pillow 后任一):
|
||||
python3 web/detective/gen/gen_cover.py
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
W, H = 1080, 1920
|
||||
FONT_CANDIDATES = [
|
||||
"/home/xsl/SoundGame/web/rhythm/gen/NotoSansSC-Bold.ttf",
|
||||
"/home/xsl/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf",
|
||||
]
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cover.png")
|
||||
|
||||
|
||||
def load_font(size):
|
||||
for p in FONT_CANDIDATES:
|
||||
if os.path.exists(p):
|
||||
return ImageFont.truetype(p, size)
|
||||
raise SystemExit("未找到中文字体,请放一份 NotoSansSC-Bold.ttf 到 web/rhythm/gen/")
|
||||
|
||||
|
||||
def main():
|
||||
rng = random.Random(918) # 固定 seed:可复现
|
||||
img = Image.new("RGB", (W, H), (10, 12, 20))
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 背景竖向渐变:夜空墨蓝 -> 街面冷灰(雨夜街道)
|
||||
for y in range(H):
|
||||
t = y / H
|
||||
d.line([(0, y), (W, y)],
|
||||
fill=(int(14 + 10 * t), int(17 + 12 * t), int(26 + 18 * t)))
|
||||
|
||||
# 一盏路灯暖光(左上,光晕压暗)——雨夜里唯一的暖色
|
||||
lx, ly = 250, 380
|
||||
lamp = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
ld = ImageDraw.Draw(lamp)
|
||||
for r, alpha in [(420, 26), (300, 40), (190, 58), (110, 80)]:
|
||||
ld.ellipse([lx - r, ly - r, lx + r, ly + r], fill=(253, 230, 138, alpha))
|
||||
lamp = lamp.filter(ImageFilter.GaussianBlur(14))
|
||||
img.paste(lamp, (0, 0), lamp)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.ellipse([lx - 26, ly - 26, lx + 26, ly + 26], fill=(255, 244, 214))
|
||||
|
||||
# 雨丝:斜细线两景深(远淡近亮),风斜角 ~8°
|
||||
rain = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
rd = ImageDraw.Draw(rain)
|
||||
for _ in range(90): # 远景雨
|
||||
x = rng.randint(0, W + 200)
|
||||
y = rng.randint(0, H)
|
||||
ln = rng.randint(40, 90)
|
||||
rd.line([(x, y), (x - ln * 0.14, y + ln)],
|
||||
fill=(148, 163, 184, rng.randint(16, 34)), width=1)
|
||||
for _ in range(60): # 近景雨
|
||||
x = rng.randint(0, W + 200)
|
||||
y = rng.randint(0, H)
|
||||
ln = rng.randint(90, 160)
|
||||
rd.line([(x, y), (x - ln * 0.14, y + ln)],
|
||||
fill=(203, 213, 225, rng.randint(28, 52)), width=2)
|
||||
img.paste(rain, (0, 0), rain)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 中央:案卷卷宗剪影(翻开的两页,暗金色边线)——「卷宗六份」的具象
|
||||
cx, cy = W // 2, 640
|
||||
doc = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
ddoc = ImageDraw.Draw(doc)
|
||||
ddoc.polygon([(cx - 40, cy - 120), (cx - 250, cy - 60), (cx - 250, cy + 190), (cx - 40, cy + 130)],
|
||||
outline=(120, 113, 108, 220), width=4)
|
||||
ddoc.polygon([(cx + 40, cy - 120), (cx + 250, cy - 60), (cx + 250, cy + 190), (cx + 40, cy + 130)],
|
||||
outline=(120, 113, 108, 220), width=4)
|
||||
# 页面行文线(证词的横线,右页夹一条对不上的红线)
|
||||
for i in range(5):
|
||||
yy = cy - 26 + i * 40
|
||||
ddoc.line([(cx - 215, yy - 28), (cx - 75, yy - 46)], fill=(148, 163, 184, 120), width=3)
|
||||
ddoc.line([(cx + 75, yy - 46), (cx + 215, yy - 28)], fill=(148, 163, 184, 120), width=3)
|
||||
ddoc.line([(cx + 92, cy + 62), (cx + 208, cy + 80)], fill=(248, 113, 113, 200), width=4)
|
||||
img.paste(doc, (0, 0), doc)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 标题(居中)与副题
|
||||
f_title = load_font(172)
|
||||
f_sub = load_font(54)
|
||||
f_small = load_font(38)
|
||||
|
||||
def ctext(y, s, font, fill, spacing=8):
|
||||
bbox = d.textbbox((0, 0), s, font=font)
|
||||
w = bbox[2] - bbox[0]
|
||||
x = (W - w) / 2
|
||||
for ch in s:
|
||||
d.text((x, y), ch, font=font, fill=fill)
|
||||
bb = d.textbbox((0, 0), ch, font=font)
|
||||
x += (bb[2] - bb[0]) + spacing
|
||||
|
||||
ctext(1010, "雨夜专案", f_title, (237, 233, 254), spacing=16)
|
||||
ctext(1262, "证词会撒谎,证据不会", f_sub, (148, 163, 184))
|
||||
|
||||
# 手势图例(极简两行)
|
||||
hints = ["左右滑 换卷宗", "单击 听取", "长按 指证", "上滑 进度", "下滑 菜单"]
|
||||
for i, s in enumerate(hints):
|
||||
col, row = i % 3, i // 3
|
||||
d.text((110 + col * 300, 1420 + row * 76), s, font=f_small, fill=(104, 122, 152))
|
||||
|
||||
ctext(1700, "翻听六份卷宗,指出矛盾,锁定真凶。", f_small, (86, 102, 130))
|
||||
|
||||
img.save(OUT)
|
||||
print(OUT, img.size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《雨夜专案》SYN 程序合成音效(批次 2,18-detective.md §8.2 标注 SYN 的条目)。
|
||||
|
||||
6 个 id(其余 6 条为 Stable Audio 3 条目,见 gen_sfx_sa3.py):
|
||||
sig_shun 小顺签名:布掸子拍柜台两下(双拍软拍,间隔 0.16s)
|
||||
sig_wen 温太太签名:门环叩两下(金属环叩木门,二连击)
|
||||
sig_fore 法医签名:纸页翻动 + 钢笔划线(噪声簌 + 笔尖刮擦两段)
|
||||
contra_hit 指证命中:不和谐金属小二度相击 + 盖章尾(全游戏最强确认音,-4dBFS)
|
||||
contra_miss 指证落空:钝木鱼一下(非 error 音色、不威吓,-8dBFS)
|
||||
turn_tick 看门狗 L1:880Hz 木鱼族轻响(dungeon 同配方自存一份,-8dBFS)
|
||||
|
||||
签名 earcon 规格:条目切换/指证选择/状态复述时前置(0.3-0.5s 量级),节拍可数
|
||||
(两下=可辨身份),电平中低不抢人声。
|
||||
|
||||
可复现:本脚本入库,一键出 web/detective/audio/sfx/*.{ogg,m4a};中间 wav 在
|
||||
gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k;
|
||||
one-shot 真峰值 <= -3dBFS(解码回读法)。
|
||||
|
||||
用法:.venv-tts/bin/python web/detective/gen/gen_sfx.py (纯 CPU,无 GPU 依赖)
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
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")
|
||||
DST = os.path.join(HERE, "..", "audio", "sfx")
|
||||
FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg"
|
||||
os.makedirs(STAGING, exist_ok=True)
|
||||
os.makedirs(DST, exist_ok=True)
|
||||
|
||||
|
||||
def t(d):
|
||||
return np.arange(int(SR * d)) / SR
|
||||
|
||||
|
||||
def env_exp(d, attack, release):
|
||||
n = int(SR * d)
|
||||
e = np.ones(n)
|
||||
na, nr = max(1, int(SR * attack)), max(1, int(SR * release))
|
||||
e[:na] = np.linspace(0, 1, na)
|
||||
e[-nr:] *= np.linspace(1, 0, nr)
|
||||
return e
|
||||
|
||||
|
||||
def lowpass(x, k):
|
||||
return np.convolve(x, np.ones(k) / k, mode="same")
|
||||
|
||||
|
||||
def bandpass(x, lo, hi):
|
||||
kh, kl = max(1, int(SR / (np.pi * hi))), max(1, int(SR / (np.pi * lo)))
|
||||
return lowpass(x, kh) - lowpass(x, kl)
|
||||
|
||||
|
||||
def save(name, sig, peak_db=-6.0):
|
||||
"""mono 存。工艺同 tools/fix_clicks.py:去直流 → 首尾 8ms cos² 淡入淡出 →
|
||||
峰值归一指定 dBFS(签名 earcon 默认 -6,contra_hit -4,miss/tick -8)。"""
|
||||
sig = sig - np.mean(sig)
|
||||
nf = max(1, int(SR * 0.008))
|
||||
fade = 0.5 * (1 - np.cos(np.pi * np.arange(nf) / nf))
|
||||
sig = sig.astype(np.float64).copy()
|
||||
sig[:nf] *= fade
|
||||
sig[-nf:] *= fade[::-1]
|
||||
sig = sig / (np.max(np.abs(sig)) + 1e-9) * 10 ** (peak_db / 20)
|
||||
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
|
||||
|
||||
|
||||
rng = np.random.default_rng(20260909)
|
||||
|
||||
# ---- sig_shun:布掸子拍柜台两下(软布拍击:中频噪声团 + 轻微柜台木腔共鸣) ----
|
||||
def duster_pat(dur=0.10):
|
||||
x = t(dur)
|
||||
pat = bandpass(rng.standard_normal(len(x)), 250, 1600) * env_exp(dur, 0.012, 0.05)
|
||||
wood = 0.4 * np.sin(2 * np.pi * 190 * x) * np.exp(-x * 55)
|
||||
return pat + wood
|
||||
|
||||
y = np.zeros(int(SR * 0.42), dtype=np.float64)
|
||||
for i, amp in enumerate([1.0, 0.85]):
|
||||
k = duster_pat()
|
||||
s = int(SR * (0.02 + i * 0.16))
|
||||
y[s:s + len(k)] += k * amp
|
||||
save("sig_shun", y)
|
||||
|
||||
# ---- sig_wen:门环叩两下(金属环叩木门:非谐波金属簇 + 门板低频响应,二连击) ----
|
||||
def knocker(dur=0.13):
|
||||
x = t(dur)
|
||||
ring = sum(a * np.sin(2 * np.pi * fr * x) * np.exp(-x * dc)
|
||||
for fr, a, dc in [(880, 1.0, 30), (1350, 0.5, 42), (2210, 0.3, 55)])
|
||||
door = 0.55 * np.sin(2 * np.pi * 120 * x) * np.exp(-x * 40)
|
||||
thud = lowpass(rng.standard_normal(len(x)), 80) * np.exp(-x * 70) * 0.45
|
||||
return ring + door + thud
|
||||
|
||||
y = np.zeros(int(SR * 0.46), dtype=np.float64)
|
||||
for i, amp in enumerate([1.0, 0.8]):
|
||||
k = knocker()
|
||||
s = int(SR * (0.02 + i * 0.18))
|
||||
y[s:s + len(k)] += k * amp
|
||||
save("sig_wen", y)
|
||||
|
||||
# ---- sig_fore:纸页翻动 + 钢笔划线(簌簌噪声扫频 + 笔尖刮擦两划) ----
|
||||
d = 0.30
|
||||
x = t(d)
|
||||
page = bandpass(rng.standard_normal(len(x)), 900, 5000) * env_exp(d, 0.02, 0.10) * 0.8 # 翻页簌
|
||||
y = page.copy()
|
||||
for start, dur2 in [(0.14, 0.10), (0.21, 0.07)]: # 钢笔两划
|
||||
seg = t(dur2)
|
||||
pen = bandpass(np.random.default_rng(int(start * 1000)).standard_normal(len(seg)),
|
||||
1500, 4500) * env_exp(dur2, 0.01, 0.03) * 0.5
|
||||
s = int(SR * start)
|
||||
y[s:s + len(pen)] += pen
|
||||
save("sig_fore", y)
|
||||
|
||||
# ---- contra_hit:不和谐金属小二度相击 + 盖章尾(矛盾证实音,全游戏最强确认音) ----
|
||||
# 1250/1320Hz 小二度拍频互殴 0.18s -> 顿 0.05s -> 低频盖章闷击(thunk)收尾。
|
||||
d1 = 0.18
|
||||
x1 = t(d1)
|
||||
clash = (np.sin(2 * np.pi * 1250 * x1) + np.sin(2 * np.pi * 1320 * x1)
|
||||
+ 0.4 * np.sin(2 * np.pi * 2500 * x1) + 0.3 * np.sin(2 * np.pi * 2640 * x1)) * np.exp(-x1 * 13)
|
||||
d2 = 0.16
|
||||
x2 = t(d2)
|
||||
stamp = (np.sin(2 * np.pi * 95 * x2) * np.exp(-x2 * 22)
|
||||
+ 0.6 * lowpass(rng.standard_normal(len(x2)), 70) * np.exp(-x2 * 40))
|
||||
save("contra_hit", np.concatenate([clash, np.zeros(int(SR * 0.05)), stamp, np.zeros(int(SR * 0.08))]),
|
||||
peak_db=-4.0)
|
||||
|
||||
# ---- contra_miss:钝木鱼一下(低频短木腔音,非 error 音色) ----
|
||||
d = 0.16
|
||||
x = t(d)
|
||||
save("contra_miss", np.sin(2 * np.pi * 310 * x) * np.exp(-x * 30)
|
||||
+ 0.5 * np.sin(2 * np.pi * 620 * x) * np.exp(-x * 45)
|
||||
+ 0.3 * lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 70),
|
||||
peak_db=-8.0)
|
||||
|
||||
# ---- turn_tick:880Hz 木鱼族轻响(dungeon 同配方自存一份;无进展轨 L1 不占台词通道) ----
|
||||
d = 0.16
|
||||
x = t(d)
|
||||
save("turn_tick", np.sin(2 * np.pi * 880 * x) * np.exp(-x * 38)
|
||||
+ 0.35 * np.sin(2 * np.pi * 1760 * x) * np.exp(-x * 52),
|
||||
peak_db=-8.0)
|
||||
|
||||
|
||||
def encode(name):
|
||||
"""staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。
|
||||
工艺:源尾部垫 50ms 静音(编码器软着陆)+ highpass=f=30(抑制解码直流再生);
|
||||
纯弦 AAC 过冲按实测迭代补 volume(abyss link_beep 同坑,dungeon 补偿同款)。"""
|
||||
src = os.path.join(STAGING, name + ".wav")
|
||||
base, _sr0 = sf.read(src, always_2d=True)
|
||||
pad_t = np.zeros((int(SR * 0.05), base.shape[1]), dtype=np.float64)
|
||||
base = np.concatenate([base, pad_t])
|
||||
for ext, cmd in [("ogg", ["-c:a", "libvorbis", "-q:a", "4"]),
|
||||
("m4a", ["-c:a", "aac", "-b:a", "128k"])]:
|
||||
out = os.path.join(DST, f"{name}.{ext}")
|
||||
vol, dc_adj = 0.0, 0.0
|
||||
tmp = "/tmp/_sfx_enc_det.wav"
|
||||
for _attempt in range(4):
|
||||
sig = base - dc_adj
|
||||
if vol < 0:
|
||||
sig = sig * (10 ** (vol / 20))
|
||||
sf.write(tmp, sig.astype(np.float32), SR)
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", tmp, "-af", "highpass=f=30",
|
||||
"-ar", "44100", *cmd, out], check=True)
|
||||
chk = "/tmp/_sfx_qc_det.wav"
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True)
|
||||
y, _sr = sf.read(chk, always_2d=True)
|
||||
tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9)
|
||||
dcv = float(np.mean(y.mean(axis=1)))
|
||||
if tp <= -3.0 and abs(dcv) <= 0.001:
|
||||
break
|
||||
if tp > -3.0:
|
||||
vol -= (tp + 3.0) + 0.25
|
||||
if abs(dcv) > 0.001:
|
||||
dc_adj -= dcv
|
||||
else:
|
||||
raise AssertionError(f"{name}.{ext}: 补偿后仍超限 tp={tp:.2f}dBFS dc={dcv:.4f}")
|
||||
print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS" + (f" (vol {vol:+.2f}dB)" if vol else ""))
|
||||
|
||||
|
||||
for _n in ["sig_shun", "sig_wen", "sig_fore", "contra_hit", "contra_miss", "turn_tick"]:
|
||||
encode(_n)
|
||||
print("detective SYN sfx done: 6 files x2 formats")
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《雨夜专案》Stable Audio 3 small-sfx 音效(批次 2,18-detective.md §8.2 SA3 条目)。
|
||||
|
||||
生成(GPU,flock 串行;模型 gated 走 hf-mirror,见 env.d/sfx.sh):
|
||||
python3 gen_sfx_sa3.py --emit # 打印生成命令,原样执行
|
||||
生成 6 条原始 wav 到 gen/staging/sfx_sa3/detective_<i>.wav(顺序 = 下表 idx)。
|
||||
本脚本随后做后处理:修整(签名 earcon 按 max_s 压进 0.3-1.0s 量级——条目切换/
|
||||
指证选择前置音,长了拖慢导航)/去直流/8ms cos² 淡化/ 响度定档 → ogg q4 +
|
||||
m4a 128k(highpass=f=30,one-shot 前后垫 60/120ms 静音)→ 解码回读 QC:逐声道
|
||||
真峰值 <= -3dBFS、|直流| <= 0.001;超限降增益 + 首轮重编加 18kHz 低通,最多 3 轮。
|
||||
|
||||
prompt:paper_turn/gavel 直出文档英文原稿;sig_ge/sig_zhou/sig_scene/rain_swell
|
||||
为文档中文拟声条的英文化(信源:18-detective.md §8.2)。定档:
|
||||
gavel -4(裁决仪式拍)、paper_turn -7(导航细节反馈)
|
||||
sig_* -6(签名前置音,可辨不抢人声)、rain_swell -10(认罪链垫,压在 bgm/verdict 下)
|
||||
|
||||
用法:.venv-tts/bin/python web/detective/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 = 20260909
|
||||
|
||||
# (id, gen 秒, prompt, 定档 dBFS, trim 上限 s)
|
||||
ITEMS = [
|
||||
("paper_turn", 3.0, "single paper page turn on desk, dry, close", -7.0, 0.9),
|
||||
("sig_ge", 3.0, "Enamel mug thumped down on a wooden desk, single dull metallic knock, close", -6.0, 0.7),
|
||||
("sig_zhou", 3.0, "Wooden abacus beads flicked twice, dry clacking, close up", -6.0, 0.9),
|
||||
("sig_scene", 4.0, "Leather shoes stepping through rain puddles twice, then one umbrella shake, night rain, close", -6.0, 1.1),
|
||||
("gavel", 4.0, "wooden gavel single strike, small room reverb", -4.0, 3.0),
|
||||
("rain_swell", 9.0, "Night rain swelling heavier then gradually settling, steady rain on windows and wet street, 6 seconds", -10.0, 7.0),
|
||||
]
|
||||
|
||||
WIN = {} # 事件窗(raw 定长 latent 床):首轮自然修剪,实测偏离再回填
|
||||
|
||||
|
||||
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}/detective.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])
|
||||
|
||||
|
||||
def encode(name, sig):
|
||||
base = sig.astype(np.float64)
|
||||
vol, lpf, dc_adj = 0.0, False, 0.0
|
||||
tmp = os.path.join(STAGING, "_enc.wav")
|
||||
chk = os.path.join(STAGING, "_qc.wav")
|
||||
res = {}
|
||||
for attempt in range(4):
|
||||
x = base - dc_adj
|
||||
if vol < 0:
|
||||
x = x * (10 ** (vol / 20))
|
||||
sf.write(tmp, x.astype(np.float32), SR)
|
||||
af = "highpass=f=30,lowpass=f=18000" if lpf else "highpass=f=30"
|
||||
for ext, cmd in [("ogg", ["-c:a", "libvorbis", "-q:a", "4"]),
|
||||
("m4a", ["-c:a", "aac", "-b:a", "128k"])]:
|
||||
out = os.path.join(DST, f"{name}.{ext}")
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", tmp, "-af", af,
|
||||
"-ar", "44100", *cmd, out], check=True)
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True)
|
||||
y, sr = sf.read(chk, always_2d=True)
|
||||
tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9)
|
||||
dcv = float(np.mean(y.mean(axis=1)))
|
||||
rms = 20 * np.log10(np.sqrt(np.mean(y ** 2)) + 1e-9)
|
||||
ok_tp, ok_dc = tp <= -3.0, abs(dcv) <= 0.001
|
||||
res[ext] = (tp, dcv, rms, y.shape[0] / sr)
|
||||
if not (ok_tp and ok_dc):
|
||||
if not ok_tp:
|
||||
vol -= (tp + 3.0) + 0.25
|
||||
lpf = True
|
||||
if not ok_dc:
|
||||
dc_adj -= dcv
|
||||
break
|
||||
else:
|
||||
break
|
||||
else:
|
||||
raise AssertionError(f"{name}: 补偿 3 轮后仍超限 {res}")
|
||||
return res
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(STAGING, exist_ok=True)
|
||||
if "--emit" in sys.argv:
|
||||
emit()
|
||||
return
|
||||
print(f"| {'id':12s} | {'时长s':>6s} | {'ogg峰dBFS':>9s} | {'m4a峰dBFS':>9s} | {'RMS dBFS':>8s} | 直流 |")
|
||||
for i, (name, _d, _p, tgt, max_s) in enumerate(ITEMS):
|
||||
raw = os.path.join(STAGING, f"detective_{i}.wav")
|
||||
y, sr = sf.read(raw, always_2d=True, dtype="float64")
|
||||
assert sr == SR, f"{name}: 采样率 {sr}"
|
||||
if y.shape[1] == 1:
|
||||
y = np.stack([y[:, 0], y[:, 0]], axis=1)
|
||||
sig = prep_oneshot(y, tgt, win=WIN.get(name), max_s=max_s)
|
||||
res = encode(name, sig)
|
||||
tp_o, dc, rms, dur = res["ogg"]
|
||||
print(f"| {name:12s} | {dur:6.2f} | {tp_o:9.2f} | {res['m4a'][0]:9.2f} | {rms:8.2f} | {dc:+.4f} |")
|
||||
print("detective SA3 sfx done -> web/detective/audio/sfx/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user