四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
555 lines
20 KiB
Python
555 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""《星环舰长》程序合成音效(22-bridge.md §3.2/§9.2,27 基 id SYN + 8 pan 派生)。
|
||
|
||
落盘:staging wav → tools/gen_bridge_syn_wav/<id>.wav(44.1kHz 立体声);
|
||
成品 → web/bridge/audio/sfx/<id>.{ogg,m4a}(tools/bin/ffmpeg 双格式)。
|
||
SA3 危机签名 4 件(center)由 tools/gen_bridge_sfx.sh 生成到同 staging 目录,
|
||
本脚本负责:其余 27 件 SYN + sig_* 的 _l/_r pan 派生(ffmpeg stereotools sbal ±0.8,
|
||
dungeon mob_step 工艺)+ 全部 39 件的编码与 QC。
|
||
|
||
规格要点(§3.2,节奏写死、playtest 逐毫秒断言):
|
||
巡检音 2.0s、间隔 0.4s;引擎 60-110Hz 低频族 / 护盾 1.2-2.8kHz 中高频族 /
|
||
扫描 800-1600Hz 硬声像左 / 武器 400-900Hz 硬声像右(固定舰桥布局,pan 烤进资产);
|
||
离线 = sys_offline 180Hz×3 慢平音全系统共用;方位尾音 dir_bow 600→1200Hz 上行 /
|
||
dir_stern 1200→500Hz 下行 0.3s。
|
||
|
||
QC(asset-pipeline 标准):one-shot 去直流 + 首尾 8ms cos² + 编码后解码回读
|
||
真峰值 ≤ -3dBFS(超限自动降档重编);pan 派生与扫描/武器音做声道能量断言
|
||
(目标声道占比 >90%);另把实测时长写 durations.json 供 playtest 资产侧断言。
|
||
|
||
用法:.venv-tts/bin/python tools/gen_bridge_syn.py
|
||
"""
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
|
||
import numpy as np
|
||
import soundfile as sf
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg")
|
||
OUT = os.path.join(ROOT, "tools", "gen_bridge_syn_wav")
|
||
SA3 = os.path.join(ROOT, "tools", "gen_bridge_sfx_wav")
|
||
DST = os.path.join(ROOT, "web", "bridge", "audio", "sfx")
|
||
os.makedirs(OUT, exist_ok=True)
|
||
os.makedirs(DST, exist_ok=True)
|
||
|
||
sr = 44100
|
||
ONE_SHOT_TP_CEIL = -3.0 # dBFS
|
||
SAMPLE_PEAK = 0.66 # ≈ -3.6dBFS,给 MDCT 过冲留余量
|
||
FADE_N = int(sr * 0.008) # 8ms cos²
|
||
|
||
_rng = np.random.default_rng(22)
|
||
|
||
|
||
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 pan_stereo(y, pan):
|
||
"""常功率声像:pan=-1 全左 / +1 全右(扫描 L / 武器 R 用 ±0.85)。"""
|
||
ang = (pan + 1) * np.pi / 4
|
||
return np.stack([y * np.cos(ang), y * np.sin(ang)], 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 harm_stack(n, f0, f1, harms, vib_hz=0.0, vib_depth=0.0):
|
||
y = np.zeros(n)
|
||
for r, g in harms:
|
||
y += g * sine_track(n, f0 * r, f1 * r, 1.0, vib_hz, vib_depth)
|
||
return y
|
||
|
||
|
||
def thump(n, f, decay, noise_band=None, noise_gain=0.0):
|
||
t = np.arange(n) / sr
|
||
y = np.sin(2 * np.pi * f * t) * np.exp(-t * decay)
|
||
if noise_band:
|
||
y += bp_noise(n, noise_band[0], noise_band[1]) * np.exp(-t * decay * 1.4) * noise_gain
|
||
return y
|
||
|
||
|
||
# ---------------------------------------------------------------- 系统状态音(2.0s 巡检格)
|
||
def r_sys_engine_ok(): # 两记低鸣脉冲各 0.45s,t=0.0/1.05(60-110Hz 低频族)
|
||
n = int(sr * 2.0)
|
||
y = np.zeros(n)
|
||
for st in (0.0, 1.05):
|
||
L = int(sr * 0.45)
|
||
tt = np.arange(L) / sr
|
||
purr = harm_stack(L, 92, 78, [(1, 1.0), (2, 0.4), (3, 0.15)], vib_hz=6, vib_depth=0.02)
|
||
purr *= (1 - np.exp(-tt * 40)) * np.exp(-tt * 6.5)
|
||
place(y, st, purr)
|
||
return y
|
||
|
||
|
||
def r_sys_engine_dmg(): # 三记断奏闷咳 0.25s,t=0.0/0.7/1.4,第三记加失真
|
||
n = int(sr * 2.0)
|
||
y = np.zeros(n)
|
||
for i, st in enumerate((0.0, 0.7, 1.4)):
|
||
L = int(sr * 0.25)
|
||
tt = np.arange(L) / sr
|
||
cough = harm_stack(L, 130, 60, [(1, 1.0), (2, 0.5)], vib_hz=18, vib_depth=0.06)
|
||
cough *= (1 - np.exp(-tt * 90)) * np.exp(-tt * 16)
|
||
if i == 2: # 失真:软削波 + 杂斑
|
||
cough = np.tanh(cough * 2.2)
|
||
cough += bp_noise(L, 200, 900) * np.exp(-tt * 14) * 0.25
|
||
place(y, st, cough * (0.9 - i * 0.12))
|
||
return y
|
||
|
||
|
||
def r_sys_shield_ok(): # 均匀薄雾嗡垫 2.0s 无起伏(1.2-2.8kHz)
|
||
n = int(sr * 2.0)
|
||
t = np.arange(n) / sr
|
||
y = bp_noise(n, 1200, 2800)
|
||
# 恒定包络(首尾 15ms 微坡防爆)
|
||
env = np.minimum(1, np.minimum(t, 2.0 - t) / 0.015 + 1e-9)
|
||
return y * env * 0.5
|
||
|
||
|
||
def r_sys_shield_dmg(): # 同垫 + 8Hz 抖颤,两次熄灭 t=0.5-0.7 / 1.3-1.6
|
||
n = int(sr * 2.0)
|
||
t = np.arange(n) / sr
|
||
y = bp_noise(n, 1200, 2800)
|
||
vib = 1 + 0.22 * np.sin(2 * np.pi * 8 * t)
|
||
gate = np.ones(n)
|
||
for a, b in ((0.5, 0.7), (1.3, 1.6)):
|
||
gate[(t >= a) & (t < b)] = 0.06
|
||
edge = np.minimum(1, np.minimum(t, 2.0 - t) / 0.015 + 1e-9)
|
||
return y * vib * gate * edge * 0.5
|
||
|
||
|
||
def r_sys_scan_ok(): # 两声匀速扫掠各 0.6s,t=0.2/1.2(800-1600Hz)
|
||
n = int(sr * 2.0)
|
||
y = np.zeros(n)
|
||
for st in (0.2, 1.2):
|
||
L = int(sr * 0.6)
|
||
sw = sine_track(L, 800, 1600, 0.8) + sine_track(L, 1600, 3200, 0.15)
|
||
tt = np.arange(L) / sr
|
||
sw *= (1 - np.exp(-tt * 60)) * np.exp(-tt * 5) * 0.8
|
||
place(y, st, sw)
|
||
return y
|
||
|
||
|
||
def r_sys_scan_dmg(): # 单声扫掠(t=0.2)+ 尾部杂斑 0.3s(t=1.4)
|
||
n = int(sr * 2.0)
|
||
y = np.zeros(n)
|
||
L = int(sr * 0.6)
|
||
sw = sine_track(L, 800, 1600, 0.8) + sine_track(L, 1600, 3200, 0.15)
|
||
tt = np.arange(L) / sr
|
||
sw *= (1 - np.exp(-tt * 60)) * np.exp(-tt * 5) * 0.8
|
||
place(y, 0.2, sw)
|
||
L2 = int(sr * 0.3)
|
||
blob = bp_noise(L2, 900, 2600) * np.exp(-np.arange(L2) / sr * 10)
|
||
place(y, 1.4, blob * 0.5)
|
||
return y
|
||
|
||
|
||
def r_sys_weapon_ok(): # 单音渐强充能 1.6s 满充收尾(400-900Hz)
|
||
n = int(sr * 2.0)
|
||
t = np.arange(n) / sr
|
||
f = np.where(t < 1.6, 400 + (900 - 400) * (t / 1.6), 900)
|
||
y = sine_track(n, 400, 900, 1.0) * 0.6 + sine_track(n, 800, 1800, 0.3) * 0.3
|
||
env = np.clip(t / 1.6, 0, 1) ** 1.6
|
||
env[t > 1.6] = np.exp(-(t[t > 1.6] - 1.6) * 6)
|
||
return y * env
|
||
|
||
|
||
def r_sys_weapon_dmg(): # 两次短充中断各 0.5s,t=0.2/1.1,各带中断嘶
|
||
n = int(sr * 2.0)
|
||
y = np.zeros(n)
|
||
for st in (0.2, 1.1):
|
||
L = int(sr * 0.5)
|
||
tt = np.arange(L) / sr
|
||
chg = sine_track(L, 400, 700, 0.9) * np.clip(tt / 0.3, 0, 1)
|
||
hiss = bp_noise(L, 2000, 6000) * np.exp(-np.maximum(tt - 0.32, 0) * 30) * 0.5
|
||
cut = np.where(tt < 0.32, 1.0, 0.08)
|
||
place(y, st, (chg + hiss) * cut)
|
||
return y
|
||
|
||
|
||
def r_sys_offline(): # 180Hz×3 慢平音各 0.15s,t=0.0/0.7/1.4(全系统共用死寂平线)
|
||
n = int(sr * 2.0)
|
||
y = np.zeros(n)
|
||
for st in (0.0, 0.7, 1.4):
|
||
L = int(sr * 0.15)
|
||
tt = np.arange(L) / sr
|
||
flat = np.sin(2 * np.pi * 180 * tt) * 0.7
|
||
flat *= (1 - np.exp(-tt * 120)) * np.exp(-tt * 22)
|
||
place(y, st, flat)
|
||
return y
|
||
|
||
|
||
# ---------------------------------------------------------------- 告警脉冲(0.4-0.5s 单响)
|
||
def r_alm_engine(): # 0.45s 单声闷咳(受损引擎签名,周期 8s)
|
||
n = int(sr * 0.45)
|
||
return thump(n, 95, 10, (80, 400), 0.6)
|
||
|
||
|
||
def r_alm_shield(): # 0.4s 闪烁哔(1.2-2.8kHz 族)
|
||
n = int(sr * 0.40)
|
||
t = np.arange(n) / sr
|
||
y = sine_track(n, 2100, 1900, 0.7) + sine_track(n, 4200, 3800, 0.2)
|
||
return y * (1 - np.exp(-t * 200)) * np.exp(-t * 12)
|
||
|
||
|
||
def r_alm_scan(): # 0.5s 失锁双哔(800-1600Hz 族,两哔间隔 0.16s)
|
||
n = int(sr * 0.50)
|
||
y = np.zeros(n)
|
||
for st in (0.0, 0.16):
|
||
L = n - int(st * sr)
|
||
tt = np.arange(L) / sr
|
||
b = sine_track(L, 1400, 1100, 0.8) * np.exp(-tt * 18)
|
||
place(y, st, b)
|
||
return y
|
||
|
||
|
||
def r_alm_weapon(): # 0.5s 欠充降调(400-900Hz 族,上滑后跌落)
|
||
n = int(sr * 0.50)
|
||
t = np.arange(n) / sr
|
||
y = sine_track(n, 500, 880, 0.7) * np.clip(t / 0.18, 0, 1)
|
||
y += sine_track(n, 880, 300, 0.5) * np.exp(-np.maximum(t - 0.2, 0) * 9) * (t > 0.2)
|
||
return y
|
||
|
||
|
||
# ---------------------------------------------------------------- 方位尾音(0.3s)
|
||
def r_dir_bow(): # 上行 600→1200Hz
|
||
n = int(sr * 0.30)
|
||
t = np.arange(n) / sr
|
||
y = sine_track(n, 600, 1200, 0.8) + sine_track(n, 1200, 2400, 0.15)
|
||
return y * np.sin(np.pi * np.clip(t / 0.3, 0, 1)) ** 1.2
|
||
|
||
|
||
def r_dir_stern(): # 下行 1200→500Hz
|
||
n = int(sr * 0.30)
|
||
t = np.arange(n) / sr
|
||
y = sine_track(n, 1200, 500, 0.8) + sine_track(n, 2400, 1000, 0.15)
|
||
return y * np.sin(np.pi * np.clip(t / 0.3, 0, 1)) ** 1.2
|
||
|
||
|
||
# ---------------------------------------------------------------- 下令/演出
|
||
def r_pa_chime(): # 全舰广播双音 0.5s(A4→E5 钟声)
|
||
n = int(sr * 0.50)
|
||
y = np.zeros(n)
|
||
for st, f in ((0.0, 440.0), (0.16, 659.25)):
|
||
L = n - int(st * sr)
|
||
tt = np.arange(L) / sr
|
||
bell = sum(g * np.sin(2 * np.pi * f * r * tt) * np.exp(-tt * d)
|
||
for r, g, d in [(1, 1.0, 9), (2.76, 0.22, 15)])
|
||
place(y, st, bell * 0.55)
|
||
return y
|
||
|
||
|
||
def r_eva_ok(): # 侧推掠风 0.8s:下扫带通噪声(规避机动)
|
||
n = int(sr * 0.80)
|
||
t = np.arange(n) / sr
|
||
x = bp_noise(n, 300, 2400)
|
||
# 频域内容上扫:rfft bin 频率随时间线性升(构造上扫掠感)
|
||
X = np.fft.rfft(x)
|
||
bins = len(X)
|
||
ramp = np.linspace(1.0, 2.6, bins) # 低 bin 提升少、高 bin 提升多 = 音内容上移
|
||
y = np.fft.irfft(X * ramp, n)
|
||
y /= np.max(np.abs(y)) + 1e-9
|
||
env = (1 - np.exp(-t * 30)) * np.exp(-np.maximum(t - 0.5, 0) * 8)
|
||
return y * env * 0.9
|
||
|
||
|
||
def r_wpn_fire(): # 炮击 0.9s:低频爆 + 高吱尾
|
||
n = int(sr * 0.90)
|
||
t = np.arange(n) / sr
|
||
blast = thump(n, 75, 12, (60, 500), 0.9)
|
||
squeal = sine_track(n, 2600, 3400, 0.25) * np.exp(-t * 8)
|
||
return blast * 1.1 + squeal
|
||
|
||
|
||
def r_shield_bear(): # 承击闷鸣 0.7s:120Hz 家族闷响 + 1.2kHz 膜振
|
||
n = int(sr * 0.70)
|
||
t = np.arange(n) / sr
|
||
y = thump(n, 118, 8, (90, 700), 0.5)
|
||
y += sine_track(n, 1250, 1100, 0.3) * np.exp(-t * 7)
|
||
return y
|
||
|
||
|
||
def r_warp(): # 驰援跃迁滑音 1.1s:上滑谐波簇 + 尾部星光
|
||
n = int(sr * 1.10)
|
||
t = np.arange(n) / sr
|
||
y = harm_stack(n, 300, 1400, [(1, 1.0), (2, 0.4), (3, 0.15)])
|
||
y *= (1 - np.exp(-t * 20)) * np.exp(-np.maximum(t - 0.8, 0) * 7)
|
||
return y * 0.8
|
||
|
||
|
||
def r_hit_hull(): # 船体受创 0.5s:120Hz 闷击 + 金属残响
|
||
n = int(sr * 0.50)
|
||
t = np.arange(n) / sr
|
||
y = thump(n, 120, 14, (80, 600), 0.8)
|
||
ring = sine_track(n, 1730, 1690, 0.12) * np.exp(-t * 6)
|
||
ring += sine_track(n, 2610, 2550, 0.06) * np.exp(-t * 5)
|
||
return y + ring
|
||
|
||
|
||
def r_res_pick(): # 资源入账 0.3s:清脆双击(B5→E6)
|
||
n = int(sr * 0.30)
|
||
y = np.zeros(n)
|
||
for st, f in ((0.0, 987.77), (0.11, 1318.5)):
|
||
L = n - int(st * sr)
|
||
tt = np.arange(L) / sr
|
||
c = (np.sin(2 * np.pi * f * tt) + 0.4 * np.sin(2 * np.pi * f * 2.01 * tt)) * np.exp(-tt * 26)
|
||
place(y, st, c * 0.5)
|
||
return y
|
||
|
||
|
||
def r_wrench(): # 扳手两响 0.5s:金属敲击 ×2
|
||
n = int(sr * 0.50)
|
||
y = np.zeros(n)
|
||
for st in (0.0, 0.22):
|
||
L = n - int(st * sr)
|
||
tt = np.arange(L) / sr
|
||
clank = bp_noise(L, 900, 3400) * np.exp(-tt * 30)
|
||
clank += sine_track(L, 620, 590, 0.5) * np.exp(-tt * 22)
|
||
place(y, st, clank * 0.8)
|
||
return y
|
||
|
||
|
||
def r_seg_warp(): # 段间跃迁 1.2s:上行滑音 + 辉煌收尾
|
||
n = int(sr * 1.20)
|
||
t = np.arange(n) / sr
|
||
y = harm_stack(n, 220, 880, [(1, 1.0), (2, 0.5), (3, 0.2)], vib_hz=5, vib_depth=0.01)
|
||
y *= (1 - np.exp(-t * 12)) * np.exp(-np.maximum(t - 0.95, 0) * 8)
|
||
return y * 0.85
|
||
|
||
|
||
# ---------------------------------------------------------------- 计时/问询
|
||
def r_tick_fast(): # 决策窗尾段 tick 0.1s(1kHz)
|
||
n = int(sr * 0.10)
|
||
t = np.arange(n) / sr
|
||
return np.sin(2 * np.pi * 1000 * t) * np.exp(-t * 40) * 0.7
|
||
|
||
|
||
def r_turn_tick(): # 无进展轨 L1 木鱼 0.15s(880Hz 家族)
|
||
n = int(sr * 0.15)
|
||
t = np.arange(n) / sr
|
||
y = np.sin(2 * np.pi * 880 * t) * np.exp(-t * 30)
|
||
y += bp_noise(n, 1200, 3600) * np.exp(-t * 36) * 0.4
|
||
return y * 0.7
|
||
|
||
|
||
def r_cruise_ping(): # 巡航柔和问询 0.4s(E5 圆头 ping)
|
||
n = int(sr * 0.40)
|
||
t = np.arange(n) / sr
|
||
y = sine_track(n, 659.25, 659.25, 0.8)
|
||
y += sine_track(n, 1318.5, 1318.5, 0.2)
|
||
return y * np.sin(np.pi * np.clip(t / 0.4, 0, 1)) ** 1.4 * 0.6
|
||
|
||
|
||
# (id, render, pan) — pan:None=中央双声道同信号;数值走常功率声像烤进资产。
|
||
SYN = {
|
||
"sys_engine_ok": (r_sys_engine_ok, None),
|
||
"sys_engine_dmg": (r_sys_engine_dmg, None),
|
||
"sys_shield_ok": (r_sys_shield_ok, None),
|
||
"sys_shield_dmg": (r_sys_shield_dmg, None),
|
||
"sys_scan_ok": (r_sys_scan_ok, -0.85), # 左舷
|
||
"sys_scan_dmg": (r_sys_scan_dmg, -0.85),
|
||
"sys_weapon_ok": (r_sys_weapon_ok, 0.85), # 右舷
|
||
"sys_weapon_dmg": (r_sys_weapon_dmg, 0.85),
|
||
"sys_offline": (r_sys_offline, None),
|
||
"alm_engine": (r_alm_engine, None),
|
||
"alm_shield": (r_alm_shield, None),
|
||
"alm_scan": (r_alm_scan, -0.85),
|
||
"alm_weapon": (r_alm_weapon, 0.85),
|
||
"dir_bow": (r_dir_bow, None),
|
||
"dir_stern": (r_dir_stern, None),
|
||
"pa_chime": (r_pa_chime, None),
|
||
"eva_ok": (r_eva_ok, None),
|
||
"wpn_fire": (r_wpn_fire, None),
|
||
"shield_bear": (r_shield_bear, None),
|
||
"warp": (r_warp, None),
|
||
"hit_hull": (r_hit_hull, None),
|
||
"res_pick": (r_res_pick, None),
|
||
"wrench": (r_wrench, None),
|
||
"seg_warp": (r_seg_warp, None),
|
||
"tick_fast": (r_tick_fast, None),
|
||
"turn_tick": (r_turn_tick, None),
|
||
"cruise_ping": (r_cruise_ping, None),
|
||
}
|
||
|
||
SIG_PAN = ["sig_meteor", "sig_pirate", "sig_distress", "sig_radiation"] # SA3 center → _l/_r
|
||
|
||
|
||
# ---------------------------------------------------------------- 编码 + QC
|
||
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 channel_ratio(path, left=True):
|
||
"""目标声道能量占比(左=True 取 L/(L+R))。"""
|
||
data, _ = sf.read(path)
|
||
if data.ndim == 1:
|
||
return 1.0
|
||
l = float(np.mean(data[:, 0] ** 2))
|
||
r = float(np.mean(data[:, 1] ** 2))
|
||
tot = l + r + 1e-12
|
||
return (l / tot) if left else (r / tot)
|
||
|
||
|
||
def derive_pan(src_wav, dst_wav, sbal):
|
||
"""常功率声像派生(与 SYN 资产同工艺;stereotools 对本 ffmpeg 构建不可靠,numpy 直出)。"""
|
||
y, _sr = sf.read(src_wav)
|
||
if y.ndim > 1:
|
||
y = np.mean(y, axis=1)
|
||
y = dc(y)
|
||
m = np.max(np.abs(y))
|
||
if m > 0:
|
||
y = y / m * SAMPLE_PEAK
|
||
ang = (sbal + 1) * np.pi / 4
|
||
data = np.stack([y * np.cos(ang), y * np.sin(ang)], axis=1).astype(np.float32)
|
||
sf.write(dst_wav, data, sr)
|
||
|
||
|
||
def main():
|
||
report, problems = [], []
|
||
durations = {}
|
||
|
||
for fid, (fn, pan) in SYN.items():
|
||
wav_path = os.path.join(OUT, fid + ".wav")
|
||
base = os.path.join(DST, fid)
|
||
y = fade(norm(dc(fn())))
|
||
# pan None/0 = 中央双声道同信号;±0.85 = 常功率硬声像(扫描 L / 武器 R)
|
||
data = pan_stereo(y, pan or 0.0)
|
||
sf.write(wav_path, data, sr)
|
||
encode(wav_path, base)
|
||
tp = check_tp(base)
|
||
if tp > ONE_SHOT_TP_CEIL:
|
||
gain = 10 ** ((ONE_SHOT_TP_CEIL - tp) / 20) * 0.98
|
||
sf.write(wav_path, data * gain, sr)
|
||
encode(wav_path, base)
|
||
tp = check_tp(base)
|
||
note = ""
|
||
if pan is not None and pan < 0:
|
||
cr = channel_ratio(wav_path, left=True)
|
||
note = f"L 占比 {cr * 100:.1f}%"
|
||
if cr < 0.90:
|
||
problems.append(f"{fid}: 左声道占比 {cr:.2f} < 0.90")
|
||
elif pan is not None and pan > 0:
|
||
cr = channel_ratio(wav_path, left=False)
|
||
note = f"R 占比 {cr * 100:.1f}%"
|
||
if cr < 0.90:
|
||
problems.append(f"{fid}: 右声道占比 {cr:.2f} < 0.90")
|
||
if tp > ONE_SHOT_TP_CEIL:
|
||
problems.append(f"{fid}: 真峰值 {tp:.2f} dBFS 超 {ONE_SHOT_TP_CEIL}")
|
||
durations[fid] = round(len(y) / sr, 3)
|
||
report.append((fid, len(y) / sr, tp, note))
|
||
|
||
# SA3 签名:center 烤 QC + pan 派生(脚本可独立重跑;SA3 缺失时派生跳过)
|
||
for fid in SIG_PAN:
|
||
src = os.path.join(SA3, fid + ".wav")
|
||
if not os.path.exists(src):
|
||
problems.append(f"{fid}: SA3 签名缺失(tools/gen_bridge_sfx.sh 未产出)")
|
||
continue
|
||
y, _ = sf.read(src)
|
||
if y.ndim > 1:
|
||
y = np.mean(y, axis=1)
|
||
y = fade(norm(dc(y)))
|
||
cw = os.path.join(OUT, fid + ".wav")
|
||
sf.write(cw, stereo(y), sr)
|
||
base = os.path.join(DST, fid)
|
||
encode(cw, base)
|
||
tp = check_tp(base)
|
||
if tp > ONE_SHOT_TP_CEIL:
|
||
gain = 10 ** ((ONE_SHOT_TP_CEIL - tp) / 20) * 0.98
|
||
sf.write(cw, stereo(y * gain), sr)
|
||
encode(cw, base)
|
||
tp = check_tp(base)
|
||
if tp > ONE_SHOT_TP_CEIL:
|
||
problems.append(f"{fid}: 真峰值 {tp:.2f} dBFS 超 {ONE_SHOT_TP_CEIL}")
|
||
durations[fid] = round(len(y) / sr, 3)
|
||
report.append((fid, len(y) / sr, tp, "SA3 center"))
|
||
for suf, sbal in (("_l", -0.8), ("_r", 0.8)):
|
||
pid = fid + suf
|
||
pw = os.path.join(OUT, pid + ".wav")
|
||
derive_pan(cw, pw, sbal)
|
||
base = os.path.join(DST, pid)
|
||
encode(pw, base)
|
||
tp = check_tp(base)
|
||
if tp > ONE_SHOT_TP_CEIL:
|
||
problems.append(f"{pid}: 真峰值 {tp:.2f} dBFS 超 {ONE_SHOT_TP_CEIL}")
|
||
cr = channel_ratio(pw, left=(sbal < 0))
|
||
if cr < 0.90:
|
||
problems.append(f"{pid}: pan 派生声道占比 {cr:.2f} < 0.90")
|
||
durations[pid] = durations[fid]
|
||
report.append((pid, durations[fid], tp,
|
||
f"pan {'L' if sbal < 0 else 'R'} {cr * 100:.1f}%"))
|
||
|
||
with open(os.path.join(OUT, "durations.json"), "w", encoding="utf-8") as f:
|
||
json.dump(durations, f, ensure_ascii=False, indent=1)
|
||
|
||
print(f"{'id':16s} {'dur(s)':>7s} {'TP(dBFS)':>9s} note")
|
||
for fid, dur, tp, note in report:
|
||
print(f"{fid:16s} {dur:7.2f} {tp:9.2f} {note}")
|
||
if problems:
|
||
print("PROBLEMS:")
|
||
for p in problems:
|
||
print(" ", p)
|
||
return 1
|
||
print(f"ALL OK: {len(report)} files -> {DST}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|