Files
SoundGame/web/radio/gen/gen_sfx.py
T
xsl d870a75a26 feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账)
- 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产)
- 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿
- radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema)
- dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链)
- 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿)
- 大厅:新增 radio/dungeon 卡片 + GAMES 数组
- harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记
- R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份
- R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
2026-09-02 18:52:37 +08:00

127 lines
6.0 KiB
Python
Raw 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 -*-
"""《不眠频率》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 真铃声到位后可整文件替换,接口不变)
可复现:本脚本入库,一键出 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")