#!/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/.wav(44.1kHz 立体声); 成品 → web/bridge/audio/sfx/.{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())