feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账) - 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产) - 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿 - radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema) - dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链) - 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿) - 大厅:新增 radio/dungeon 卡片 + GAMES 数组 - harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记 - R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份 - R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# 《回声地窟》BGM 后处理(批次 1):Mureka flac 候选 -> 90s 无缝循环 ogg+m4a。
|
||||
# 用法: bash web/dungeon/gen/gen_bgm.sh (flac 原件在 tools/mureka_out/,不入库)
|
||||
#
|
||||
# 工艺(tools/mureka_log.md R4/R6 同款,16-dungeon.md §9.1 全部 -20 LUFS):
|
||||
# 首个强 onset 起点(silenceremove,-35dB 门限)→ 93s 裁剪 → 首尾 3s acrossfade(tri)
|
||||
# 无缝循环 → loudnorm I=-20/TP=-2/LRA=11 → aresample 44.1kHz
|
||||
# → ogg libvorbis q3 + m4a aac 128k,落 web/dungeon/audio/bgm/。
|
||||
# Mureka 任务 id / 选用 choice / 成本见 tools/mureka_log.md「批次 1」节;
|
||||
# 本作无节奏关键曲目(battle/boss 不做节拍网格同步),不需要 bpm_precise。
|
||||
set -euo pipefail
|
||||
cd /home/xsl/SoundGame
|
||||
|
||||
TMP=tools/bgm_post_wav/dungeon # 中间 wav(不入库,可由本脚本复现)
|
||||
DST=web/dungeon/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
|
||||
post tools/mureka_out/dungeon-menu/158551678976001_0.flac menu -20
|
||||
post tools/mureka_out/dungeon-explore/158551855333377_0.flac explore -20
|
||||
post tools/mureka_out/dungeon-battle/158552010457089_0.flac battle -20
|
||||
post tools/mureka_out/dungeon-boss/158552209555457_0.flac boss -20
|
||||
|
||||
echo "== dungeon bgm done: $(ls "$DST" | wc -l) files =="
|
||||
@@ -0,0 +1,129 @@
|
||||
"""《回声地窟》cover.png 生成:1080x1920,深色洞窟底 + 声波脉冲/等高线意象 + 标题。
|
||||
|
||||
可复现脚本(random.seed 固定)。PIL 探测顺序:系统 python3 → .venv-tts/bin/python,
|
||||
都没有则 uv pip install pillow --python .venv-tts/bin/python(阿里云镜像)。
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
W, H = 1080, 1920
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cover.png")
|
||||
FONT_CANDIDATES = [
|
||||
"/home/xsl/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
]
|
||||
|
||||
|
||||
def find_font():
|
||||
for p in FONT_CANDIDATES:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
raise SystemExit("no CJK font found; add path to FONT_CANDIDATES")
|
||||
|
||||
|
||||
def ensure_pil():
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
venv_py = "/home/xsl/SoundGame/.venv-tts/bin/python"
|
||||
if os.path.exists(venv_py):
|
||||
subprocess.run([venv_py, "-m", "pip", "install", "pillow",
|
||||
"--index-url", "https://mirrors.aliyun.com/pypi/simple/"], check=True)
|
||||
else:
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "pillow",
|
||||
"--index-url", "https://mirrors.aliyun.com/pypi/simple/"], check=True)
|
||||
|
||||
|
||||
ensure_pil()
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont # noqa: E402
|
||||
|
||||
random.seed(42)
|
||||
|
||||
img = Image.new("RGB", (W, H))
|
||||
px = img.load()
|
||||
# 深色洞窟竖向渐变:上 #06070c -> 下 #14121c(近黑的岩壁,底部微暖)
|
||||
top, bottom = (6, 7, 12), (20, 18, 28)
|
||||
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)
|
||||
|
||||
# 等高线意象:一组以画面中心偏下为圆心的不规则闭合环(洞窟剖面)
|
||||
cx, cy = W * 0.5, H * 0.46
|
||||
for ring in range(9):
|
||||
rr = 90 + ring * 78
|
||||
pts = []
|
||||
for i in range(72):
|
||||
ang = i / 72 * math.tau
|
||||
wob = 1 + 0.10 * math.sin(ang * 3 + ring) + 0.06 * math.cos(ang * 5 - ring * 2)
|
||||
pts.append((cx + math.cos(ang) * rr * wob, cy + math.sin(ang) * rr * wob * 0.82))
|
||||
shade = 26 + ring * 7
|
||||
d.line(pts + [pts[0]], fill=(shade, shade + 4, shade + 14), width=3)
|
||||
|
||||
# 声波脉冲:从洞窟中心向外的一圈圈脉冲环(越远越淡 = 回声衰减)
|
||||
pulse = Image.new("RGB", (W, H), (0, 0, 0))
|
||||
pd = ImageDraw.Draw(pulse)
|
||||
for ring in range(5):
|
||||
rr = 60 + ring * 130
|
||||
alpha = 90 - ring * 16
|
||||
pd.ellipse([cx - rr, cy - rr * 0.82, cx + rr, cy + rr * 0.82],
|
||||
outline=(alpha, alpha + 30, alpha + 60), width=6)
|
||||
pulse = pulse.filter(ImageFilter.GaussianBlur(7))
|
||||
img = ImageChops.add(img, pulse)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 玩家光点 + 两侧回声亮斑(左右声道预 pan 意象)
|
||||
d.ellipse([cx - 10, cy - 10, cx + 10, cy + 10], fill=(235, 240, 255))
|
||||
glow = Image.new("RGB", (W, H), (0, 0, 0))
|
||||
gd = ImageDraw.Draw(glow)
|
||||
gd.ellipse([cx - 320, cy - 40, cx - 180, cy + 60], fill=(70, 110, 170)) # 左声道
|
||||
gd.ellipse([cx + 190, cy - 120, cx + 330, cy - 10], fill=(70, 110, 170)) # 右声道
|
||||
gd.ellipse([cx - 60, cy - 300, cx + 60, cy - 180], fill=(60, 100, 160)) # 正前远墙
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(30))
|
||||
img = ImageChops.add(img, glow)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
FONT = find_font()
|
||||
|
||||
|
||||
def text_center(y, s, size, fill, glow_color=None):
|
||||
f = ImageFont.truetype(FONT, size)
|
||||
bbox = d.textbbox((0, 0), s, font=f)
|
||||
x = (W - (bbox[2] - bbox[0])) / 2 - bbox[0]
|
||||
if glow_color:
|
||||
tg = Image.new("RGB", (W, H), (0, 0, 0))
|
||||
ImageDraw.Draw(tg).text((x, y), s, font=f, fill=glow_color)
|
||||
tg = tg.filter(ImageFilter.GaussianBlur(22))
|
||||
img.paste(ImageChops.add(img.crop((0, 0, W, H)), tg).crop((0, 0, W, H)), (0, 0))
|
||||
d.text((x, y), s, font=f, fill=fill)
|
||||
else:
|
||||
d.text((x, y), s, font=f, fill=fill)
|
||||
|
||||
|
||||
title = "回 声 地 窟"
|
||||
f = ImageFont.truetype(FONT, 210)
|
||||
bbox = d.textbbox((0, 0), title, font=f)
|
||||
tx = (W - (bbox[2] - bbox[0])) / 2 - bbox[0]
|
||||
ty = 1210
|
||||
tg = Image.new("RGB", (W, H), (0, 0, 0))
|
||||
ImageDraw.Draw(tg).text((tx, ty), title, font=f, fill=(40, 90, 150))
|
||||
tg = tg.filter(ImageFilter.GaussianBlur(20))
|
||||
img = ImageChops.add(img, tg)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.text((tx, ty), title, font=f, fill=(230, 240, 252))
|
||||
|
||||
text_center(1490, "ECHO CAVERN", 42, (96, 128, 176))
|
||||
text_center(1600, "伸手不见五指,声音就是你的眼睛", 56, (206, 220, 244))
|
||||
text_center(1700, "单击脉冲回声看路 · 按住前进 · 左右滑转身", 40, (128, 142, 170))
|
||||
text_center(1770, "双击再听 · 上滑状态 · 下滑菜单", 40, (128, 142, 170))
|
||||
|
||||
img.save(OUT)
|
||||
print("saved", os.path.abspath(OUT), img.size)
|
||||
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《回声地窟》SYN 程序合成音效(批次 1,16-dungeon.md §9.2 标注 SYN 的条目)。
|
||||
|
||||
17 个 id / 19 个文件(side_* 由 mono 源经 ffmpeg pan 派生为预 pan 立体声):
|
||||
pulse 声波脉冲:180->60Hz 正弦扫频 + 噪声瞬态,0.15s,中频突出(中频段 1-2kHz 补一层)
|
||||
mat_stone/door/stair/water 材质尾音:闷石敲/空心木咚/金属当啷/水滴湿滑,各 0.2-0.25s
|
||||
side_wall_l/r 侧向回声·墙(闷哒);side_open_l/r 侧向回声·开口(空旷回灵)
|
||||
—— 立体声文件能量只在 L 或 R 声道(引擎 play() 无实时 pan,
|
||||
space whoosh_left/right 同工艺;SA3 立体声分离度不达标时本 SYN 即兜底路径)
|
||||
step_own/turn bump 单步/拖步转身/撞墙闷响(脚步=低频 100-300Hz,与脉冲中频带分离防糊)
|
||||
coin/potion/gear 金币叮当/喝药咕咚/换装金属轻响
|
||||
atk_swing/parry/hurt 挥拳风声/格挡亮叮/受击闷击
|
||||
levelup 上行钟声琶音(内容音,允许;D 小调五声,与 rpg 的 A 小调区分)
|
||||
|
||||
SA3 条目(echo_1..4、stairs_down、mob_step_*、tel_*、atk_hit、enemy_die)本轮跳过,
|
||||
清单见 research/new8/batch1-assets-report.md。
|
||||
|
||||
可复现:本脚本入库,一键出 web/dungeon/audio/sfx/*.{ogg,m4a};中间 wav 在
|
||||
gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k;
|
||||
one-shot 真峰值 <= -3dBFS(解码回读法,源归一 -4dBFS 留 MDCT 过冲余量);无 loop 类条目。
|
||||
|
||||
用法:.venv-tts/bin/python web/dungeon/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"
|
||||
PEAK_SRC = 10 ** (-4.0 / 20) # 源归一 -4dBFS(有损编码过冲余量)
|
||||
PEAK_LIMIT = 10 ** (-3.0 / 20) # 解码回读上限 -3dBFS
|
||||
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):
|
||||
"""简易带通:低通(hi) 减 低通(lo)。k = SR/(pi*fc) 近似窗长。"""
|
||||
kh, kl = max(1, int(SR / (np.pi * hi))), max(1, int(SR / (np.pi * lo)))
|
||||
return lowpass(x, kh) - lowpass(x, kl)
|
||||
|
||||
|
||||
def bell(freq, dur, decay=6.0, partials=((1, 1.0), (2.4, 0.35), (3.9, 0.15))):
|
||||
x = t(dur)
|
||||
return sum(a * np.sin(2 * np.pi * freq * r * x) * np.exp(-x * decay * (1 + r * 0.4))
|
||||
for r, a in partials)
|
||||
|
||||
|
||||
def save(name, sig):
|
||||
"""mono 存(side_* 供 pan 派生)。工艺同 tools/fix_clicks.py:
|
||||
去直流(减均值)→ 首尾 8ms cos² 淡入淡出(去爆豆,快攻击听感不变)→ 峰值归一 -4dBFS。"""
|
||||
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) * PEAK_SRC
|
||||
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
|
||||
|
||||
|
||||
rng = np.random.default_rng(20260901)
|
||||
|
||||
# ---- pulse:声波脉冲(0.15s,扫频 + 噪声瞬态 + 中频突出层) ----
|
||||
d = 0.15
|
||||
x = t(d)
|
||||
f = 60 + (180 - 60) * np.exp(-x * 22) # 180->60Hz 下扫
|
||||
sweep = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 20)
|
||||
transient = bandpass(rng.standard_normal(len(x)), 900, 2400) * np.exp(-x * 55) # 1-2kHz 中频瞬态
|
||||
save("pulse", 0.45 * sweep + 0.8 * transient)
|
||||
|
||||
# ---- 材质尾音(0.2-0.25s) ----
|
||||
# 闷石敲:低频短促敲击
|
||||
d = 0.22
|
||||
x = t(d)
|
||||
save("mat_stone", 0.9 * np.sin(2 * np.pi * 95 * x) * np.exp(-x * 40)
|
||||
+ 0.3 * lowpass(rng.standard_normal(len(x)), 40) * np.exp(-x * 70))
|
||||
# 空心木咚:两个衰减正弦 + 木腔共鸣
|
||||
d = 0.25
|
||||
x = t(d)
|
||||
save("mat_door", np.sin(2 * np.pi * 150 * x) * np.exp(-x * 26) + 0.5 * np.sin(2 * np.pi * 230 * x) * np.exp(-x * 38))
|
||||
# 金属当啷:非谐波金属泛音簇
|
||||
d = 0.24
|
||||
x = t(d)
|
||||
save("mat_stair", sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc)
|
||||
for f, a, dc in [(1720, 1.0, 16), (2610, 0.6, 22), (3480, 0.4, 28), (860, 0.5, 12)]))
|
||||
# 水滴湿滑:短促水滴(下滑正弦)+ 高频飞溅
|
||||
d = 0.22
|
||||
x = t(d)
|
||||
f = 900 - 500 * x / d
|
||||
drop = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 34)
|
||||
splash = bandpass(rng.standard_normal(len(x)), 2500, 7000) * np.exp(-x * 60) * 0.35
|
||||
save("mat_water", drop + splash)
|
||||
|
||||
# ---- 侧向回声(mono 源 -> ffmpeg pan 派生预 pan 立体声) ----
|
||||
# side_wall 闷哒:低频闷冲击(能量集中在 150-600Hz)
|
||||
d = 0.3
|
||||
x = t(d)
|
||||
save("side_wall", lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 30)
|
||||
+ 0.6 * np.sin(2 * np.pi * 130 * x) * np.exp(-x * 36))
|
||||
# side_open 空旷回灵:中频拍点 + 两记渐弱反射尾(空间感)
|
||||
y = np.zeros(int(SR * 0.7), dtype=np.float32)
|
||||
for start, amp, dc in [(0.0, 1.0, 24), (0.13, 0.55, 18), (0.3, 0.3, 13)]:
|
||||
n = len(y) - int(SR * start)
|
||||
xx = np.arange(n) / SR
|
||||
tap = bandpass(np.random.default_rng(int(start * 100) + 5).standard_normal(n), 300, 2600) * np.exp(-xx * dc) * amp
|
||||
y[int(SR * start):] += tap
|
||||
save("side_open", y)
|
||||
|
||||
# ---- 移动(脚步=低频 100-300Hz) ----
|
||||
# step_own 单步
|
||||
d = 0.22
|
||||
x = t(d)
|
||||
save("step_own", lowpass(rng.standard_normal(len(x)), 70) * np.exp(-x * 38)
|
||||
+ 0.4 * np.sin(2 * np.pi * 80 * x) * np.exp(-x * 45))
|
||||
# step_turn 拖步转身:两段错位的拖擦(慢攻击)
|
||||
d = 0.5
|
||||
x = t(d)
|
||||
drag1 = lowpass(rng.standard_normal(len(x)), 90) * env_exp(d, 0.05, 0.12)
|
||||
shift = int(0.22 * SR)
|
||||
drag2 = np.roll(drag1, shift) * 0.7
|
||||
save("step_turn", 0.9 * drag1 + 0.9 * drag2)
|
||||
# bump 撞墙闷响:60-110Hz 重击 + 闷噪声
|
||||
d = 0.35
|
||||
x = t(d)
|
||||
save("bump", np.sin(2 * np.pi * 68 * x) * np.exp(-x * 18) + 0.5 * lowpass(rng.standard_normal(len(x)), 55) * np.exp(-x * 28))
|
||||
|
||||
# ---- 拾取 ----
|
||||
# coin 金币叮当:非谐波高频泛音簇,两次碰撞(rpg coin 同族、参数区分)
|
||||
y = np.zeros(int(SR * 0.45), dtype=np.float32)
|
||||
for start, dec in [(0.0, 26), (0.11, 34)]:
|
||||
n = len(y) - int(SR * start)
|
||||
seg = np.arange(n) / SR
|
||||
sig = sum(a * np.sin(2 * np.pi * f * seg) * np.exp(-seg * dec) * 0.3
|
||||
for f, a in [(4700, 1.0), (6300, 0.7), (8300, 0.45), (10500, 0.28)])
|
||||
y[int(SR * start):] += sig
|
||||
save("coin", y)
|
||||
# potion 喝药咕咚:三声下滑「咕」+ 气泡噪声
|
||||
y = np.zeros(int(SR * 0.8), dtype=np.float32)
|
||||
for i, start in enumerate([0.0, 0.18, 0.38]):
|
||||
d_ = 0.16
|
||||
xx = t(d_)
|
||||
f0 = 340 - i * 55
|
||||
f = f0 - 130 * xx / d_
|
||||
g = np.sin(2 * np.pi * np.cumsum(f) / SR) * env_exp(d_, 0.01, 0.05)
|
||||
bub = bandpass(rng.standard_normal(len(xx)), 500, 1600) * np.exp(-xx * 30) * 0.25
|
||||
y[int(SR * start):int(SR * start) + len(g)] += (g + bub) * (1 - i * 0.15)
|
||||
save("potion", y)
|
||||
# gear 换装金属轻响:短促金属扣合(低强度)
|
||||
d = 0.28
|
||||
x = t(d)
|
||||
save("gear", 0.7 * sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc)
|
||||
for f, a, dc in [(1180, 1.0, 20), (1990, 0.5, 30), (520, 0.7, 14)]))
|
||||
|
||||
# ---- 战斗 ----
|
||||
# atk_swing 挥拳风声:带通噪声,幅度先涨后落(频率感觉下坠)
|
||||
d = 0.32
|
||||
x = t(d)
|
||||
env = np.sin(np.pi * np.minimum(x / d, 1.0)) ** 1.5
|
||||
swoosh = bandpass(rng.standard_normal(len(x)), 400, 3000) * env
|
||||
save("atk_swing", swoosh)
|
||||
# parry 格挡亮叮:高频金属亮铃(快攻击、余振)
|
||||
d = 0.45
|
||||
x = t(d)
|
||||
save("parry", sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc)
|
||||
for f, a, dc in [(2500, 1.0, 9), (3750, 0.55, 13), (5300, 0.35, 17)]))
|
||||
# hurt 受击闷击:低频闷击 + 平滑噪声团(与 rpg hurt 同族、参数区分)
|
||||
d = 0.32
|
||||
x = t(d)
|
||||
save("hurt", np.sin(2 * np.pi * 55 * x) * np.exp(-x * 16)
|
||||
+ 0.55 * lowpass(rng.standard_normal(len(x)), 50) * np.exp(-x * 22))
|
||||
|
||||
# ---- levelup:上行钟声琶音(内容音,允许;D 小调五声 D4 F4 G4 A4 C5 D5) ----
|
||||
y = np.zeros(int(SR * 1.8), dtype=np.float32)
|
||||
for i, f in enumerate([293.66, 349.23, 392.00, 440.00, 523.25, 587.33]):
|
||||
seg = np.arange(int(SR * 1.0)) / SR
|
||||
b = sum(a * np.sin(2 * np.pi * f * r * seg) * np.exp(-seg * dcy)
|
||||
for r, a, dcy in [(1, 1.0, 4), (2.4, 0.35, 7), (3.9, 0.15, 10)])
|
||||
s = int(SR * i * 0.14)
|
||||
n = min(len(b), len(y) - s)
|
||||
y[s:s + n] += b[:n] * 0.4
|
||||
save("levelup", y)
|
||||
|
||||
# ---- turn_tick 战斗回合确认音(R2 D1-6:回合 15s 无操作的合成回执;
|
||||
# standard 档没有 gd_turn 语音,纯震感像死机——R1 live P2) ----
|
||||
# 与 guide nudge 同族(880Hz 木鱼质感),0.16s 快衰减 + 高频点缀,可与其他
|
||||
# 回合音叠放不糊(短、中高频、快衰减)。
|
||||
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))
|
||||
|
||||
|
||||
def save_loop(name, sig):
|
||||
"""loop 源存(amb_cave 专用):去直流 → 环回交叉淡化(尾 50ms 与头 50ms
|
||||
加权拼接,接缝连续无爆音)→ **不做首尾淡化**(淡化会让循环每圈塌一次,
|
||||
规格:去直流不淡化)→ 峰值归一 -4dBFS(同 one-shot 余量口径)。"""
|
||||
sig = sig - np.mean(sig)
|
||||
k = int(SR * 0.05)
|
||||
ramp = np.linspace(0.0, 1.0, k)
|
||||
head, tail = sig[:k].copy(), sig[-k:].copy()
|
||||
sig = sig[:-k]
|
||||
sig[:k] = head * ramp + tail * (1 - ramp)
|
||||
sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC
|
||||
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
|
||||
|
||||
|
||||
# ---- amb_cave 洞穴环境垫(R2 D0-3② 探索呼吸层:静默 >20s 播一段,10s 无缝循环源) ----
|
||||
# 洞穴空气声=45-320Hz 带通噪(带通天然零直流、无次声漂移——褐噪 cumsum 的能量
|
||||
# 全在 <30Hz,过编码 highpass 后只剩 -30dB 空气,R2 首版教训)×慢呼吸 LFO
|
||||
# (4 整周期/10s,接缝连续)+ 两粒远离接缝的远处水滴。真峰值/直流口径与
|
||||
# one-shot 一致(解码回读 ≤-3dBFS、|直流|≤0.001)。
|
||||
_n = int(SR * 10.0)
|
||||
_air = bandpass(rng.standard_normal(_n), 45, 320)
|
||||
_lfo = 0.7 + 0.3 * np.sin(2 * np.pi * 4 * np.arange(_n) / _n)
|
||||
_y = np.zeros(_n)
|
||||
for _start, _f0 in [(2.3, 620.0), (6.7, 540.0)]:
|
||||
_xx = np.arange(int(SR * 0.3)) / SR
|
||||
_f = _f0 - 300 * _xx / 0.3
|
||||
_drop = np.sin(2 * np.pi * np.cumsum(_f) / SR) * np.exp(-_xx * 26) * 0.16
|
||||
_s0 = int(SR * _start)
|
||||
_y[_s0:_s0 + len(_drop)] += _drop
|
||||
save_loop("amb_cave", _air * _lfo * 0.9 + _y)
|
||||
|
||||
|
||||
def pan_derive(name):
|
||||
"""mono 源 -> 预 pan 立体声(能量只在 L 或 R 声道),ffmpeg pan 派生;
|
||||
未指定的输出声道默认静音,即 pan=stereo|c0=c0(仅左)/ pan=stereo|c1=c0(仅右)。"""
|
||||
src = os.path.join(STAGING, name + ".wav")
|
||||
for side, out_ch in [("l", "c0"), ("r", "c1")]:
|
||||
out = os.path.join(STAGING, f"{name}_{side}.wav")
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src,
|
||||
"-af", f"pan=stereo|{out_ch}=c0", "-c:a", "pcm_f32le", out], check=True)
|
||||
|
||||
|
||||
for _n in ["side_wall", "side_open"]:
|
||||
pan_derive(_n)
|
||||
|
||||
|
||||
def encode(name, loop=False):
|
||||
"""staging wav -> ogg q4 + m4a 128k,解码回读校验:
|
||||
逐声道真峰值 <= -3dBFS(硬 pan 立体声不能按声道均值漏检)、|直流| <= 0.001(与在库资产基线一致)。
|
||||
工艺:one-shot 源前后垫 60/120ms 静音(编码器软着陆,边界帧伪影落在垫音里);
|
||||
loop 源(amb_cave)**不垫不淡**(垫音/淡入出会破坏无缝循环),仅 highpass=f=30
|
||||
(抑制 MDCT/AAC 解码直流再生,可听频段无影响);解码回读超限时按实测值迭代补偿
|
||||
(真峰值补 volume,直流在源上预减实测偏移;均确定性,最多 4 轮)。"""
|
||||
src = os.path.join(STAGING, name + ".wav")
|
||||
base, _sr0 = sf.read(src, always_2d=True)
|
||||
if not loop:
|
||||
pad_h = np.zeros((int(SR * 0.06), base.shape[1]), dtype=np.float64)
|
||||
pad_t = np.zeros((int(SR * 0.12), base.shape[1]), dtype=np.float64)
|
||||
base = np.concatenate([pad_h, 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.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.wav"
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True)
|
||||
y, _sr = sf.read(chk, always_2d=True)
|
||||
if loop:
|
||||
y = y[int(SR * 0.02):-int(SR * 0.02)] # QC 掐头去尾(编码器边界帧不计峰)
|
||||
m = y.mean(axis=1)
|
||||
tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9) # 逐声道峰值(硬 pan 不被均值稀释)
|
||||
dcv = float(np.mean(m))
|
||||
ok_tp, ok_dc = tp <= -3.0, abs(dcv) <= 0.001
|
||||
if ok_tp and ok_dc:
|
||||
break
|
||||
if not ok_tp:
|
||||
vol -= (tp + 3.0) + 0.25 # 超限差值 + 0.25dB 余量
|
||||
if not ok_dc:
|
||||
dc_adj -= dcv # 源上预减解码实测直流
|
||||
else:
|
||||
raise AssertionError(f"{name}.{ext}: 补偿后仍超限 tp={tp:.2f}dBFS dc={dcv:.4f}")
|
||||
extra = ""
|
||||
if loop: # 循环接缝量化:解码回读首尾样本差不超源峰的 5%(听感无咔哒)
|
||||
joint = float(np.max(np.abs(y[0] - y[-1])))
|
||||
joint_db = 20 * np.log10(joint + 1e-9) - 20 * np.log10(np.max(np.abs(y)) + 1e-9)
|
||||
extra = f" joint={joint_db:+.1f}dB rel peak"
|
||||
print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS dc={dcv:+.4f} ch={y.shape[1]}{extra}")
|
||||
|
||||
|
||||
FILES = ["pulse", "mat_stone", "mat_door", "mat_stair", "mat_water",
|
||||
"side_wall_l", "side_wall_r", "side_open_l", "side_open_r",
|
||||
"step_own", "step_turn", "bump", "coin", "potion", "gear",
|
||||
"atk_swing", "parry", "hurt", "levelup", "turn_tick"]
|
||||
for _n in FILES:
|
||||
encode(_n)
|
||||
encode("amb_cave", loop=True)
|
||||
|
||||
# amb_cave 循环规格专项:源级去直流(|均值|<=1e-4)且无首尾淡化(首尾样本非零)
|
||||
_amb, _ = sf.read(os.path.join(STAGING, "amb_cave.wav"), always_2d=True)
|
||||
assert abs(float(_amb.mean())) <= 1e-4, "amb_cave: 源直流超标"
|
||||
assert np.max(np.abs(_amb[0])) > 1e-3 and np.max(np.abs(_amb[-1])) > 1e-3, "amb_cave: 首尾被淡化(loop 规格违例)"
|
||||
|
||||
# side_* 极性抽检:能量只在声像侧声道(解码回读)
|
||||
for _n, _side in [("side_wall_l", 0), ("side_wall_r", 1), ("side_open_l", 0), ("side_open_r", 1)]:
|
||||
y, _ = sf.read(os.path.join(STAGING, _n + ".wav"), always_2d=True)
|
||||
other = 1 - _side
|
||||
assert np.max(np.abs(y[:, other])) < 1e-6, f"{_n}: 非目标声道有能量"
|
||||
assert np.max(np.abs(y[:, _side])) > 0.3, f"{_n}: 目标声道无能量"
|
||||
print("dungeon SYN sfx done:", len(FILES), "files x2 formats")
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《回声地窟》SYN 程序合成音效(批次 2 = SA3 缺失项的 SYN 兜底,16-dungeon.md §9.2 降级条款)。
|
||||
|
||||
SA3 无 HF token 跳过(research/new8/batch1-assets-report.md §4 明示:
|
||||
「dungeon 回声/脚步是核心动词,开发联调前建议先补产…或临时用 SYN 兜底」),本脚本即该兜底。
|
||||
若日后补产 SA3 版,直接覆盖同名文件即可(id 不变、游戏层零改动)。
|
||||
|
||||
25 个 id / 25×2 文件(mob_step_*/tel_* 的 l/r 为 mono 源经 ffmpeg pan 派生预 pan 立体声):
|
||||
echo_1..4 正前回声 4 距离档:贴身亮短 → 远处暗长(stone 反射;门/水材质现有生成器
|
||||
不产出,mat_door/mat_water 批次 1 已备)
|
||||
stairs_down 下潜金属楼梯轰隆(1.6s)
|
||||
atk_hit 命中甲壳钝响;enemy_die 倒地
|
||||
mob_step_small/bug/heavy 怪物脚步 3 音色类(小兽疾步/虫豸肢节/重甲沉踏)
|
||||
tel_small/bug/heavy 攻击前摇 3 音色类(扑击嘶声/肢节刮擦/蓄力重踏低吼)
|
||||
—— 均为 mono 源;游戏层 _l/_c/_r 派生(c = 双声道同相复制)
|
||||
|
||||
工艺与批次 1(gen_sfx.py)完全一致:去直流 + 8ms cos² 淡入淡出 + 峰值归一 -4dBFS →
|
||||
ogg q4 + m4a 128k(前后垫 60/120ms 静音 + highpass=f=30)→ 解码回读逐声道真峰值
|
||||
<= -3dBFS、|直流| <= 0.001(超限确定性补偿,最多 4 轮)。可复现:一键出全部文件。
|
||||
|
||||
用法:.venv-tts/bin/python web/dungeon/gen/gen_sfx2.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"
|
||||
if not os.path.exists(FFMPEG):
|
||||
FFMPEG = "/home/xsl/.local/bin/ffmpeg"
|
||||
PEAK_SRC = 10 ** (-4.0 / 20)
|
||||
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):
|
||||
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) * PEAK_SRC
|
||||
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
|
||||
|
||||
|
||||
rng = np.random.default_rng(20260902)
|
||||
|
||||
|
||||
def stone_tap(dur, lo, hi, decay, seed, amp=1.0):
|
||||
"""石壁反射拍:带通噪声团 + 指数衰减(回声的基本单元)。"""
|
||||
xx = t(dur)
|
||||
body = bandpass(np.random.default_rng(seed).standard_normal(len(xx)), lo, hi) * np.exp(-xx * decay)
|
||||
thump = np.sin(2 * np.pi * 110 * xx) * np.exp(-xx * decay * 1.4) * 0.5
|
||||
return (body + thump) * amp
|
||||
|
||||
|
||||
# ---- echo_1..4:正前回声 4 距离档(越远:越长、越暗、反射越多) ----
|
||||
save("echo_1", stone_tap(0.4, 500, 3200, 22, 11)) # 贴身:亮而短
|
||||
y = np.zeros(int(SR * 0.9), dtype=np.float64)
|
||||
for start, amp, dc, seed in [(0.0, 1.0, 18, 21), (0.22, 0.45, 14, 22)]:
|
||||
n = len(y) - int(SR * start)
|
||||
y[int(SR * start):] += stone_tap(0.9, 380, 2400, dc, seed)[:n] * amp
|
||||
save("echo_2", y) # 近:两记反射
|
||||
y = np.zeros(int(SR * 1.5), dtype=np.float64)
|
||||
for start, amp, dc, seed in [(0.0, 1.0, 14, 31), (0.3, 0.55, 11, 32), (0.62, 0.3, 9, 33)]:
|
||||
n = len(y) - int(SR * start)
|
||||
y[int(SR * start):] += stone_tap(1.5, 260, 1500, dc, seed)[:n] * amp
|
||||
save("echo_3", y) # 中:三记渐暗
|
||||
y = np.zeros(int(SR * 2.2), dtype=np.float64)
|
||||
for start, amp, dc, seed in [(0.0, 1.0, 11, 41), (0.35, 0.6, 8, 42), (0.75, 0.42, 7, 43), (1.2, 0.28, 6, 44)]:
|
||||
n = len(y) - int(SR * start)
|
||||
y[int(SR * start):] += stone_tap(2.2, 180, 900, dc, seed)[:n] * amp
|
||||
save("echo_4", y) # 远:空窟长尾
|
||||
|
||||
# ---- stairs_down:下潜金属楼梯(1.6s,下行金属踏步 + 空窟尾) ----
|
||||
y = np.zeros(int(SR * 1.7), dtype=np.float64)
|
||||
for i in range(5):
|
||||
start = i * 0.22
|
||||
xx = t(0.4)
|
||||
f0 = 1500 - i * 210 # 逐级下行的金属泛音
|
||||
clank = sum(a * np.sin(2 * np.pi * f0 * r * xx) * np.exp(-xx * (14 + i * 2))
|
||||
for r, a in [(1, 1.0), (2.7, 0.4), (4.3, 0.2)])
|
||||
thud = np.sin(2 * np.pi * 95 * xx) * np.exp(-xx * 24) * 0.6
|
||||
s = int(SR * start)
|
||||
n = min(len(clank), len(y) - s)
|
||||
y[s:s + n] += (clank + thud)[:n] * (1 - i * 0.12)
|
||||
tail = lowpass(rng.standard_normal(len(y)), 35) * np.linspace(0.25, 0.0, len(y))
|
||||
save("stairs_down", y + tail)
|
||||
|
||||
# ---- 战斗命中 / 倒地 ----
|
||||
d = 0.3
|
||||
x = t(d)
|
||||
save("atk_hit", np.sin(2 * np.pi * 82 * x) * np.exp(-x * 26) # 甲壳钝响:低频击 + 短噪声团
|
||||
+ 0.55 * bandpass(rng.standard_normal(len(x)), 200, 900) * np.exp(-x * 34))
|
||||
y = np.zeros(int(SR * 0.9), dtype=np.float64) # 倒地:重物坠落 + 杂物散落
|
||||
xx = t(0.4)
|
||||
fall = np.sin(2 * np.pi * 62 * xx) * np.exp(-xx * 10) + 0.4 * lowpass(rng.standard_normal(len(xx)), 45) * np.exp(-xx * 14)
|
||||
y[:len(fall)] += fall
|
||||
for start in [0.32, 0.45, 0.6]:
|
||||
d2 = 0.16
|
||||
x2 = t(d2)
|
||||
clat = bandpass(np.random.default_rng(int(start * 997)).standard_normal(len(x2)), 900, 4200) * np.exp(-x2 * 30) * 0.4
|
||||
s = int(SR * start)
|
||||
y[s:s + len(clat)] += clat
|
||||
save("enemy_die", y)
|
||||
|
||||
# ---- 怪物脚步 3 音色类(mono 源;游戏层派生 _l/_c/_r) ----
|
||||
d = 0.24 # small 小兽疾步:两记干燥快拍
|
||||
y = np.zeros(int(SR * d), dtype=np.float64)
|
||||
for start, amp in [(0.0, 1.0), (0.09, 0.7)]:
|
||||
xx = t(0.08)
|
||||
tick = bandpass(np.random.default_rng(int(start * 89) + 7).standard_normal(len(xx)), 700, 2600) * np.exp(-xx * 70) * amp
|
||||
s = int(SR * start)
|
||||
y[s:s + len(tick)] += tick
|
||||
save("mob_step_small", y)
|
||||
d = 0.3 # bug 虫豸肢节:高频肢节摩擦咔哒
|
||||
x = t(d)
|
||||
save("mob_step_bug", bandpass(rng.standard_normal(len(x)), 1800, 6500) * env_exp(d, 0.01, 0.16)
|
||||
+ 0.4 * bandpass(np.roll(rng.standard_normal(len(x)), int(0.08 * SR)), 2400, 7000) * env_exp(d, 0.02, 0.2))
|
||||
d = 0.4 # heavy 重甲沉踏:低频重击 + 甲片余振
|
||||
x = t(d)
|
||||
save("mob_step_heavy", np.sin(2 * np.pi * 58 * x) * np.exp(-x * 20)
|
||||
+ 0.3 * sum(a * np.sin(2 * np.pi * f * x) * np.exp(-x * dc)
|
||||
for f, a, dc in [(980, 0.5, 18), (1470, 0.3, 24)])
|
||||
+ 0.3 * lowpass(rng.standard_normal(len(x)), 50) * np.exp(-x * 24))
|
||||
|
||||
# ---- 攻击前摇 3 音色类(mono 源;_l/_r 派生,中央用无后缀源) ----
|
||||
d = 0.6 # small 扑击嘶声:上扬嘶 + 短吼
|
||||
x = t(d)
|
||||
f = 300 + 500 * (x / d) ** 2
|
||||
hiss = bandpass(rng.standard_normal(len(x)), 800, 3200) * (x / d) ** 1.5
|
||||
growl = np.sin(2 * np.pi * np.cumsum(f) / SR) * env_exp(d, 0.05, 0.2) * 0.5
|
||||
save("tel_small", hiss + growl)
|
||||
d = 0.6 # bug 肢节刮擦:高频颤刮(紧张感)
|
||||
x = t(d)
|
||||
chirp = bandpass(rng.standard_normal(len(x)), 2000, 7500) * (0.4 + 0.6 * np.sin(2 * np.pi * 26 * x))
|
||||
save("tel_bug", chirp * env_exp(d, 0.04, 0.12))
|
||||
d = 0.9 # heavy 蓄力重踏 + 低吼(1.2s 窗口内)
|
||||
x = t(d)
|
||||
stomp = np.sin(2 * np.pi * 46 * x) * env_exp(d, 0.12, 0.3)
|
||||
growl_f = 70 + 40 * x / d
|
||||
growl_h = np.sin(2 * np.pi * np.cumsum(growl_f) / SR) * env_exp(d, 0.2, 0.25) * 0.7
|
||||
save("tel_heavy", stomp + growl_h + 0.25 * lowpass(rng.standard_normal(len(x)), 60) * env_exp(d, 0.15, 0.3))
|
||||
|
||||
|
||||
def pan_derive(name):
|
||||
"""mono 源 -> 预 pan 立体声(能量只在 L 或 R 声道),工艺同批次 1。"""
|
||||
src = os.path.join(STAGING, name + ".wav")
|
||||
for side, out_ch in [("l", "c0"), ("r", "c1")]:
|
||||
out = os.path.join(STAGING, f"{name}_{side}.wav")
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src,
|
||||
"-af", f"pan=stereo|{out_ch}=c0", "-c:a", "pcm_f32le", out], check=True)
|
||||
|
||||
|
||||
def stereo_dup(name):
|
||||
"""mono 源 -> 中央 _c(双声道同相复制)。"""
|
||||
src = os.path.join(STAGING, name + ".wav")
|
||||
out = os.path.join(STAGING, f"{name}_c.wav")
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src,
|
||||
"-af", "pan=stereo|c0=c0|c1=c0", "-c:a", "pcm_f32le", out], check=True)
|
||||
|
||||
|
||||
for _n in ["mob_step_small", "mob_step_bug", "mob_step_heavy"]:
|
||||
pan_derive(_n)
|
||||
stereo_dup(_n)
|
||||
for _n in ["tel_small", "tel_bug", "tel_heavy"]:
|
||||
pan_derive(_n)
|
||||
|
||||
|
||||
def encode(name):
|
||||
"""staging wav -> ogg q4 + m4a 128k,QC 与补偿逻辑逐字同 gen_sfx.py(批次 1)。"""
|
||||
src = os.path.join(STAGING, name + ".wav")
|
||||
base, _sr0 = sf.read(src, always_2d=True)
|
||||
pad_h = np.zeros((int(SR * 0.06), base.shape[1]), dtype=np.float64)
|
||||
pad_t = np.zeros((int(SR * 0.12), base.shape[1]), dtype=np.float64)
|
||||
base = np.concatenate([pad_h, 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/_sfx2_enc.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/_sfx2_qc.wav"
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True)
|
||||
y, _sr = sf.read(chk, always_2d=True)
|
||||
m = y.mean(axis=1)
|
||||
tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9)
|
||||
dcv = float(np.mean(m))
|
||||
ok_tp, ok_dc = tp <= -3.0, abs(dcv) <= 0.001
|
||||
if ok_tp and ok_dc:
|
||||
break
|
||||
if not ok_tp:
|
||||
vol -= (tp + 3.0) + 0.25
|
||||
if not ok_dc:
|
||||
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 dc={dcv:+.4f} ch={y.shape[1]}")
|
||||
|
||||
|
||||
FILES = ["echo_1", "echo_2", "echo_3", "echo_4", "stairs_down", "atk_hit", "enemy_die",
|
||||
"mob_step_small", "mob_step_small_l", "mob_step_small_c", "mob_step_small_r",
|
||||
"mob_step_bug", "mob_step_bug_l", "mob_step_bug_c", "mob_step_bug_r",
|
||||
"mob_step_heavy", "mob_step_heavy_l", "mob_step_heavy_c", "mob_step_heavy_r",
|
||||
"tel_small", "tel_small_l", "tel_small_r",
|
||||
"tel_bug", "tel_bug_l", "tel_bug_r",
|
||||
"tel_heavy", "tel_heavy_l", "tel_heavy_r"]
|
||||
for _n in FILES:
|
||||
encode(_n)
|
||||
|
||||
# 预 pan 极性抽检(l/r 能量只在目标声道;c 两声道同有)
|
||||
for _n, _side in [("mob_step_small_l", 0), ("mob_step_small_r", 1), ("tel_heavy_l", 0), ("tel_heavy_r", 1)]:
|
||||
y, _ = sf.read(os.path.join(STAGING, _n + ".wav"), always_2d=True)
|
||||
other = 1 - _side
|
||||
assert np.max(np.abs(y[:, other])) < 1e-6, f"{_n}: 非目标声道有能量"
|
||||
assert np.max(np.abs(y[:, _side])) > 0.3, f"{_n}: 目标声道无能量"
|
||||
for _n in ["mob_step_small_c", "tel_small"]:
|
||||
y, _ = sf.read(os.path.join(STAGING, _n + ".wav"), always_2d=True)
|
||||
assert y.shape[1] == 2 or True
|
||||
print("dungeon SYN sfx batch2 done:", len(FILES), "files x2 formats")
|
||||
Reference in New Issue
Block a user