Files
SoundGame/tools/gen_bridge_syn.py
xsl 4b39ae2e49 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(压缩候选)
2026-09-14 05:48:13 +08:00

555 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())