#!/usr/bin/env python3 # -*- coding: utf-8 -*- """《不眠频率》SYN 程序合成音效(批次 1 + R2 F3,15-radio.md §5.2 标注「程序合成」的条目)。 R2 起共 3 件(其余 11 件为 Stable Audio 3 条目,HF token 未配置,本轮跳过——见批次资产报告): ui_confirm 菜单/接听确认(同全局先例:storyline gen_sfx.py 的 880->1320Hz 双音) ui_menu 菜单呼出(同全局先例:330+495Hz 软音) phone_ring RING 铃声循环垫(R2 F3:440+480Hz 柔和双音一响 1.8s + 静默 3.4s, 循环周期 5.2s ≥4s 烘进文件本身;无缝 loop 规格:逐声道去直流、 首尾**不做淡化**、循环边界静音校验;游戏侧 src.loop=true 循环播放, 接听即停——SA3 真铃声到位后可整文件替换,接口不变) ※ 2026-09-02 更新:SA3 12 件已由 gen_sfx_sa3.py 补产落地,phone_ring 已被 SA3 真铃声 同名替换(本脚本重跑会以 SYN 版覆盖回去——如需回退再跑本脚本即可,原 SYN 备份在 tools/asset_fix_backup/sa3_ab_20260902/radio/)。 可复现:本脚本入库,一键出 web/radio/audio/sfx/ui_{confirm,menu,phone_ring}.{ogg,m4a}; 中间 wav 在 gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k; one-shot 真峰值 <= -3dBFS(解码回读法校验,源归一 -4dBFS 留 MDCT 过冲余量)。 用法:.venv-tts/bin/python web/radio/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 save(name, sig): """立体声存。工艺同 tools/fix_clicks.py:逐声道去直流 → 首尾 8ms cos² 淡入淡出 → 峰值归一 -4dBFS。""" if sig.ndim == 1: sig = np.stack([sig, sig], axis=1) sig = sig - np.mean(sig, axis=0, keepdims=True) 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[:, None] sig[-nf:] *= fade[::-1][:, None] sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR) def save_loop(name, sig): """无缝 loop 立体声存(R2 F3):逐声道去直流 → **不做首尾淡化** → 峰值归一 -4dBFS。 循环边界必须落在静音(信号首尾各留 ≥0.3s 零块),loop 拼接零接缝零爆点。""" if sig.ndim == 1: sig = np.stack([sig, sig], axis=1) sig = sig - np.mean(sig, axis=0, keepdims=True) sig = sig.astype(np.float64).copy() sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR) # ---- ui_confirm:菜单/接听确认(全局先例双音;尾部留 50ms 静音给编码器软着陆) ---- d1, d2 = 0.09, 0.14 a = np.sin(2 * np.pi * 880 * t(d1)) * env_exp(d1, 0.005, 0.003) b = np.sin(2 * np.pi * 1320 * t(d2)) * env_exp(d2, 0.005, 0.012) save("ui_confirm", np.concatenate([a, np.zeros(int(SR * 0.02)), b, np.zeros(int(SR * 0.05))])) # ---- ui_menu:菜单呼出(全局先例软音) ---- 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))])) # ---- phone_ring:RING 铃声循环垫(R2 F3)---- # 440+480Hz 经典回铃双频(柔和纯弦叠加),一响 1.8s + 静默 3.4s = 5.2s 循环周期 # (≥4s,原 RING_INTERVAL_MS 节奏烘进文件)。无缝 loop:起振 60ms 线性软起音 # (首样本严格过零)+ 500ms 缓释,循环拼接点两侧均为静音零样本。 _ding, _rest = 1.8, 3.4 _xr = t(_ding) _ring = (np.sin(2 * np.pi * 440.0 * _xr) + np.sin(2 * np.pi * 480.0 * _xr)) * 0.5 _ring = _ring * env_exp(_ding, 0.06, 0.5) save_loop("phone_ring", np.concatenate([_ring, np.zeros(int(SR * _rest))])) def encode(name, loop=False): """staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。 工艺:源尾部垫 50ms 静音(编码器软着陆,one-shot)+ highpass=f=30(抑制 MDCT/AAC 解码直流再生)。 loop=True(无缝循环垫)追加边界校验:解码后首/末样本逐声道 |amp| <= -60dBFS—— 循环拼接点两侧均为零样本,src.loop=true 拼接零爆点(不做首尾淡化,见 save_loop)。""" src = os.path.join(STAGING, name + ".wav") 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", src, "-af", "highpass=f=30", "-ar", "44100", *cmd, out], check=True) chk = "/tmp/_sfx_qc.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) # 逐声道峰值 assert tp <= -3.0, f"{name}.{ext}: 真峰值 {tp:.2f}dBFS 超限" assert abs(float(np.mean(y.mean(axis=1)))) <= 0.001, f"{name}.{ext}: 直流超限" if loop: edge = 10 ** (-60.0 / 20) assert float(np.max(np.abs(y[0, :]))) <= edge, f"{name}.{ext}: 循环头样本非静音" assert float(np.max(np.abs(y[-1, :]))) <= edge, f"{name}.{ext}: 循环尾样本非静音" print(f" loop {y.shape[0] / sr:.2f}s 边界静音 ✓") print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS") for _n in ["ui_confirm", "ui_menu"]: encode(_n) encode("phone_ring", loop=True) print("radio SYN sfx done")