feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官

四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
This commit is contained in:
xsl
2026-09-14 05:48:13 +08:00
parent c8843233b0
commit 4b39ae2e49
2676 changed files with 62789 additions and 1282 deletions
+554
View File
@@ -0,0 +1,554 @@
#!/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())