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:
xsl
2026-09-10 01:40:31 +08:00
parent cd4c5ec65a
commit 31372615b2
1183 changed files with 8445 additions and 10 deletions
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# 《深海回音》BGM 后处理(批次 2):Mureka flac 候选 -> 90s 无缝循环 ogg+m4a。
# 用法: bash web/abyss/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 → aresample 44.1kHz
# → ogg libvorbis q3 + m4a aac 128k,落 web/abyss/audio/bgm/。
# 响度:4 首全部 I=-20(17-abyss.md §5.1 未给 menu 独立项——MENU 复用 deck 低响度
# 淡入 500ms 由游戏层 gain 控制,§2.1;radio menu -24 是独立 menu pad 曲的先例,不适用)。
# Mureka 任务 id / 选用 choice / 成本见 tools/mureka_log.md「批次 2」节;无节奏关键曲目,
# 不需要 bpm_precise(dive 的 sonar-like pulse 仅为 prompt 特征词)。
set -euo pipefail
cd /home/xsl/SoundGame
TMP=tools/bgm_post_wav/abyss # 中间 wav(不入库,可由本脚本复现)
DST=web/abyss/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/abyss-deck/160001236664321_0.flac deck -20 # 192.1s
post tools/mureka_out/abyss-dive/160001429798913_0.flac dive -20 # 179.8s
post tools/mureka_out/abyss-comms/160001576402945_1.flac comms -20 # 168.7s
post tools/mureka_out/abyss-ending/160001717043203_0.flac ending -20 # 191.4s
echo "== abyss bgm done: $(ls "$DST" | wc -l) files =="
+106
View File
@@ -0,0 +1,106 @@
"""web/abyss 封面:1080x1920,深潜救援主题——深海近黑蓝底、声呐同心圆、
单点脉冲光与竖排标题《深海回音》。可复现脚本(固定 seed,无外部网络依赖)。
用法(PIL 探测顺序:系统 python3 → .venv-tts → 装 pillow 后任一):
python3 web/abyss/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(922) # 固定 seed:可复现
img = Image.new("RGB", (W, H), (3, 8, 16))
d = ImageDraw.Draw(img)
# 背景竖向渐变:海面微光 -> 沟底近黑(下潜方向)
for y in range(H):
t = y / H
d.line([(0, y), (W, y)],
fill=(int(16 - 13 * t), int(34 - 26 * t), int(56 - 40 * t)))
# 远景悬浮微粒(深海水雪 marine snow)
dot = Image.new("RGBA", (W, H), (0, 0, 0, 0))
dd = ImageDraw.Draw(dot)
for _ in range(150):
x, y = rng.randint(0, W - 1), rng.randint(0, H - 1)
r = rng.choice([1, 1, 2])
dd.ellipse([x - r, y - r, x + r, y + r],
fill=(180, 210, 230, rng.randint(14, 46)))
img.paste(dot, (0, 0), dot)
d = ImageDraw.Draw(img)
# 中央:声呐同心圆(你发出的脉冲),青色环从光点向外扩散
cx, cy = W // 2, 600
for i, (r, alpha, wd) in enumerate([(348, 30, 3), (272, 48, 4), (196, 70, 5), (124, 96, 6)]):
d.ellipse([cx - r, cy - r, cx + r, cy + r],
outline=(56, 189, 248, alpha) if i % 2 == 0 else (125, 211, 252, alpha),
width=wd)
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
gd.ellipse([cx - 44, cy - 44, cx + 44, cy + 44], fill=(186, 230, 253, 200))
gd.ellipse([cx - 88, cy - 88, cx + 88, cy + 88], fill=(56, 189, 248, 60))
glow = glow.filter(ImageFilter.GaussianBlur(10))
img.paste(glow, (0, 0), glow)
d = ImageDraw.Draw(img)
d.ellipse([cx - 20, cy - 20, cx + 20, cy + 20], fill=(240, 249, 255))
# 一记微弱回波:右下偏移的暗环(「深海回音」具象)
rx, ry = cx + 250, cy + 170
for r, alpha in [(150, 40), (104, 60), (64, 84)]:
ring = Image.new("RGBA", (W, H), (0, 0, 0, 0))
rd = ImageDraw.Draw(ring)
rd.ellipse([rx - r, ry - r, rx + r, ry + r],
outline=(94, 234, 212, alpha), width=3)
ring = ring.filter(ImageFilter.GaussianBlur(1.2))
img.paste(ring, (0, 0), ring)
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(1020, "深海回音", f_title, (224, 242, 254), spacing=16)
ctext(1272, "声呐里,是被困潜艇的五个人", f_sub, (110, 134, 166))
# 手势图例(极简两行)
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=(92, 112, 142))
ctext(1700, "氧气在倒数。每次下潜,都是抉择。", f_small, (76, 94, 122))
img.save(OUT)
print(OUT, img.size)
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""《深海回音》SYN 程序合成音效(批次 2,17-abyss.md §5.2 标注 SYN 的条目)。
6 个 id(其余 12 条为 Stable Audio 3 条目,见 gen_sfx_sa3.py):
sonar_ping 你发出的声呐脉冲:1.4->0.9kHz 正弦 chirp 220ms 指数衰减,-6dBFS
(设计指定电平;「回波家族统一音头」——搜索等待单击恒出此音)
pipe_knock 管路敲击暗号(老白 SOS):三短三长三短固定节拍金属敲击
(节拍精确可复现、断言可数;短 0.12s / 长 0.36s / 间隔 0.12s)
link_beep 电台接通:双音上行 0.3s
ascent_chime 出水确认:上行三音琶音 0.4s
ui_confirm 菜单确认(全局先例:880->1320Hz 双音)
ui_menu 菜单呼出(全局先例:330+495Hz 软音)
可复现:本脚本入库,一键出 web/abyss/audio/sfx/*.{ogg,m4a};中间 wav 在
gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k;
one-shot 真峰值 <= -3dBFS(解码回读法,源归一留 MDCT 过冲余量);无 loop 类条目
(amb_* 三条走 SA3「amb 床只去直流」工艺,在 gen_sfx_sa3.py)。
用法:.venv-tts/bin/python web/abyss/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):
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=-4.0):
"""mono 存。工艺同 tools/fix_clicks.py:去直流 → 首尾 8ms cos² 淡入淡出 →
峰值归一指定 dBFS(sonar_ping 按设计 -6dBFS)。"""
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)
# ---- sonar_ping:声呐脉冲(1.4->0.9kHz 正弦 chirp 220ms 指数衰减,-6dBFS) ----
d = 0.22
x = t(d)
f = 1400 - (1400 - 900) * x / d # 1.4 -> 0.9kHz 线性下滑
chirp = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-x * 14)
click = bandpass(rng.standard_normal(len(x)), 2000, 6000) * np.exp(-x * 60) * 0.2 # 起振细瞬态
save("sonar_ping", 0.92 * chirp + click, peak_db=-6.0)
# ---- pipe_knock:管路敲击暗号(三短三长三短,SOS 节拍精确可复现/可数) ----
# 单击=金属管体振(非谐波簇 640/1490/2330Hz,短促衰减),长短仅持续不同。
def knock(dur, decay=26.0):
x = t(dur)
hit = sum(a * np.sin(2 * np.pi * fr * x) * np.exp(-x * decay * (1 + fr / 3000))
for fr, a in [(640, 1.0), (1490, 0.55), (2330, 0.3), (410, 0.5)])
thud = lowpass(rng.standard_normal(len(x)), 60) * np.exp(-x * 90) * 0.5
return (hit + thud) * env_exp(dur, 0.002, 0.01)
D_SHORT, D_LONG, GAP = 0.12, 0.36, 0.12
pattern = ([D_SHORT] * 3 + [D_LONG] * 3 + [D_SHORT] * 3) # 三短三长三短
_y = np.zeros(int(SR * (sum(pattern) + GAP * (len(pattern) - 1) + 0.15)), dtype=np.float64)
_pos = 0.15 / 2 * SR
for dur in pattern:
k = knock(dur)
_y[int(_pos):int(_pos) + len(k)] += k
_pos += int((dur + GAP) * SR)
save("pipe_knock", _y)
# ---- link_beep:电台接通双音上行 0.3s(620->930Hz,通讯族音色,与 ui 族区分) ----
d = 0.30
a = np.sin(2 * np.pi * 620 * t(0.12)) * env_exp(0.12, 0.004, 0.02)
b = np.sin(2 * np.pi * 930 * t(0.16)) * env_exp(0.16, 0.004, 0.06)
save("link_beep", np.concatenate([a, np.zeros(int(SR * 0.02)), b]))
# ---- ascent_chime:出水确认上行三音琶音 0.4s(C5-E5-G5,干净上行=「上浮完成」) ----
y = np.zeros(int(SR * 0.40), dtype=np.float64)
for i, fr in enumerate([523.25, 659.25, 783.99]):
seg = t(0.22)
tone = (np.sin(2 * np.pi * fr * seg) + 0.4 * np.sin(2 * np.pi * fr * 2 * seg)) * np.exp(-seg * 10)
s = int(SR * i * 0.09)
n = min(len(tone), len(y) - s)
y[s:s + n] += tone[:n] * 0.6
save("ascent_chime", y)
# ---- ui_confirm / ui_menu:全局先例同款(storyline/radio gen_sfx.py) ----
a = np.sin(2 * np.pi * 880 * t(0.09)) * env_exp(0.09, 0.005, 0.003)
b = np.sin(2 * np.pi * 1320 * t(0.14)) * env_exp(0.14, 0.005, 0.012)
save("ui_confirm", np.concatenate([a, np.zeros(int(SR * 0.02)), b, np.zeros(int(SR * 0.05))]))
d = 0.35
x = t(d)
save("ui_menu", np.concatenate([
(np.sin(2 * np.pi * 330 * x) + 0.5 * np.sin(2 * np.pi * 495 * x)) * env_exp(d, 0.02, 0.08),
np.zeros(int(SR * 0.05))]))
def encode(name):
"""staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。
工艺:源尾部垫 50ms 静音(编码器软着陆)+ highpass=f=30(抑制 MDCT/AAC 解码直流再生);
纯弦 AAC 过冲按实测迭代补 volume(link_beep.m4a 首轮实测 -2.82dBFS 超限,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_abyss.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_abyss.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 # 超限差值 + 0.25dB 余量
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 ["sonar_ping", "pipe_knock", "link_beep", "ascent_chime", "ui_confirm", "ui_menu"]:
encode(_n)
print("abyss SYN sfx done: 6 files x2 formats")
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""《深海回音》Stable Audio 3 small-sfx 音效(批次 2,17-abyss.md §5.2 SA3 条目)。
生成(GPU,flock 串行;模型 gated 走 hf-mirror,见 env.d/sfx.sh):
python3 gen_sfx_sa3.py --emit # 打印生成命令,原样执行
生成 12 条原始 wav 到 gen/staging/sfx_sa3/abyss_<i>.wav(顺序 = 下表 idx)。
本脚本随后做后处理:修整/去直流/8ms cos² 淡化(amb 床只去直流不淡化——游戏层
buffer.loop 循环,radio phone_ring/amb 先例)/ 响度定档 → ogg q4 + m4a 128k
(highpass=f=30,one-shot 前后垫 60/120ms 静音)→ 解码回读 QC:逐声道真峰值
<= -3dBFS、|直流| <= 0.001;超限降增益 + 首轮重编加 18kHz 低通,最多 3 轮补偿。
echo_metal_l/_r:文档 prompt 带 panned slightly left/right,但引擎无运行时 pan、
SA3 立体声分离度不可靠(dungeon 先例)——取「left」版生成源,mono 下混后 ffmpeg
pan 派生 _l/_r 预 pan 立体声(能量只在目标声道,脚本断言极性;dungeon side_* 工艺)。
prompt 均出自 17-abyss.md §5.2(英文原稿);定档理由:
spark_flash/landslide -4(D2/D4 危机拍,前置 2s 渐强同窗)
echo_metal -5 / echo_groan -6 / surf_splash -6(信息音/叙事音,主层次)
valve_close -5 / bell_toll -5(道具 payoff 音)
echo_debris -7 / static_burst -8(垫在通话底下的纹理层)
amb_* RMS -26 / 峰值帽 -8(环境床,escape/adio amb 先例)
用法:.venv-tts/bin/python web/abyss/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, 模式 oneshot/amb, 定档 dBFS, trim 上限 s)
ITEMS = [
("echo_metal", 3.0, "Sonar echo returning from a steel submarine hull, resonant metallic ping with slow decay, panned slightly left, underwater, 2 seconds", "oneshot", -5.0, 2.4),
("echo_debris", 3.0, "Sonar echo returning from an underwater rubble and debris field, diffuse granular reverberation, 2 seconds", "oneshot", -7.0, 2.6),
("echo_groan", 5.0, "Stressed submarine hull groaning deep underwater, long metallic moan under immense pressure, 4 seconds", "oneshot", -6.0, 4.5),
("static_burst", 3.0, "Shortwave radio static burst with faint ghost of a distorted voice, 2 seconds", "oneshot", -8.0, 2.4),
("amb_deep", 12.0, "Deep ocean ambient loop, low pressure hum, distant current wash, occasional far creak, seamless loop", "amb", -26.0, 0),
("amb_deck", 12.0, "Night ship deck ambience, wind, gentle waves against hull, distant rigging creak and engine hum, seamless loop", "amb", -26.0, 0),
("breath_loop", 12.0, "Slow scuba regulator breathing loop, calm but slightly labored, underwater, seamless loop", "amb", -26.0, 0),
("spark_flash", 3.0, "Underwater cutting torch sparks with a brief muffled gas pop, 2 seconds", "oneshot", -4.0, 2.6),
("valve_close", 3.0, "Heavy submarine valve wheel shutting, metal rotation clunk and water seal, 2 seconds", "oneshot", -5.0, 2.4),
("landslide", 6.0, "Underwater landslide rumble, rolling sediment and rocks, ominous low roar building, 4 seconds", "oneshot", -4.0, 5.5),
("surf_splash", 3.0, "Diver breaking through the sea surface, splash and one gasp, 2 seconds", "oneshot", -6.0, 2.6),
("bell_toll", 4.0, "Old ship brass bell, single clean toll with warm resonance, 3 seconds", "oneshot", -5.0, 3.5),
]
# 事件窗(SA3 raw 为定长 latent 床,与请求时长无关;None=按 -50dBFS 活动窗自然修剪)。
# 首轮先自然修剪,实测事件位置偏离设计时再回填窗(radio arg_muffled 同法)。
WIN = {}
def emit():
items = [it for it in ITEMS if it[3] != "skip"]
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}/abyss.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)) # 窗切在持续声中,切点 50ms 缓出
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] # 8ms cos² 淡入淡出
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 prep_amb(y, rms_db, peak_db):
"""环境床:只去直流,不淡化(游戏层 buffer.loop 循环播放);RMS 定档 + 峰值帽。"""
y = y - np.mean(y, axis=0, keepdims=True)
rms = 20 * np.log10(np.sqrt(np.mean(y ** 2)) + 1e-9)
peak = 20 * np.log10(np.max(np.abs(y)) + 1e-9)
s = min(10 ** ((rms_db - rms) / 20), 10 ** ((peak_db - peak) / 20))
return y * s
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): # 首发 + 最多 3 轮补偿(首轮重编加 18kHz 低通)
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 # 首轮重编起加 18kHz 低通压 MDCT 过冲
if not ok_dc:
dc_adj -= dcv
break
else:
break
else:
raise AssertionError(f"{name}: 补偿 3 轮后仍超限 {res}")
return res
def pan_derive(name, sig_mono):
"""mono 源 -> 预 pan 立体声 _l/_r(能量只在 L 或 R 声道),ffmpeg pan 派生
(dungeon side_* 工艺复用:引擎 play() 无实时 pan)。"""
src = os.path.join(STAGING, name + "_mono.wav")
sf.write(src, sig_mono.astype(np.float32), SR)
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)
# 极性断言(staging 源级):能量只在声像侧声道
for side, ch in [("l", 0), ("r", 1)]:
y, _ = sf.read(os.path.join(STAGING, f"{name}_{side}.wav"), always_2d=True)
assert np.max(np.abs(y[:, 1 - ch])) < 1e-6, f"{name}_{side}: 非目标声道有能量"
assert np.max(np.abs(y[:, ch])) > 0.1, f"{name}_{side}: 目标声道无能量"
def main():
os.makedirs(STAGING, exist_ok=True)
if "--emit" in sys.argv:
emit()
return
print(f"| {'id':14s} | {'时长s':>6s} | {'ogg峰dBFS':>9s} | {'m4a峰dBFS':>9s} | {'RMS dBFS':>8s} | 直流 |")
idx = 0
for name, _d, _p, mode, tgt, max_s in ITEMS:
if mode == "skip":
continue
raw = os.path.join(STAGING, f"abyss_{idx}.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)
idx += 1
if mode == "oneshot":
sig = prep_oneshot(y, tgt, win=WIN.get(name), max_s=max_s)
else:
sig = prep_amb(y, tgt, -8.0)
if name == "echo_metal":
# mono 下混 -> pan 派生 _l/_r(保留 oneshot 修整/淡化/定档后的信号)
mono = sig.mean(axis=1)
pad_h = np.zeros((int(SR * 0.06), 2))
pad_t = np.zeros((int(SR * 0.12), 2))
pan_derive(name, mono)
for side in ("l", "r"):
s2, _ = sf.read(os.path.join(STAGING, f"{name}_{side}.wav"), always_2d=True)
res = encode(f"echo_metal_{side}", np.concatenate([pad_h, s2, pad_t]))
tp_o, dc, rms, dur = res["ogg"]
print(f"| echo_metal_{side:<3s} | {dur:6.2f} | {tp_o:9.2f} | {res['m4a'][0]:9.2f} | {rms:8.2f} | {dc:+.4f} |")
continue
res = encode(name, sig)
tp_o, dc, rms, dur = res["ogg"]
tp_m = res["m4a"][0]
print(f"| {name:14s} | {dur:6.2f} | {tp_o:9.2f} | {tp_m:9.2f} | {rms:8.2f} | {dc:+.4f} |")
print("abyss SA3 sfx done -> web/abyss/audio/sfx/")
if __name__ == "__main__":
main()