Files
SoundGame/tools/gen_simon_syn.py
T
xsl 258ffd5ba3 fix(simon): 音序回响状态透明打磨(玩家代理取证迭代,玩法零改动)
- S2:新增input_open双音柔铃标记「能答了」边界(playback/input边界此前吞手势无标记)
- S1/S3/S4:input阶段长按=状态查询(13句status_*分档整句:长度4档×进度4档×账目5档,两本账口径一致,不耗账不闭窗)
- S5:announce/roundend非单击手势补error震(00 §5.4三秒反馈律)
- S6 P0:gameover下滑进菜单→结算链死锁8.4s修复(结算看守+挂起补播)
- S7:your_turn每局一次(原每轮重念)
- 取证:live两会话+4人设评审,S清单见research/simon-polish/round1/findings.md
- 验证:playtest 156/156 + guide 122 + 终检绿 + 14游戏回归零回退 + live复跑核对局14/14压力局8/8;状态感知2/5→4/5(四席同升),愿意玩改口为是
- 02号文档追加§9修订记录;新增input_open+13句status_*走完整TTS管线(flock串行QC无离群)
2026-09-14 20:17:28 +08:00

156 lines
5.5 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 -*-
"""《音序回响》程序合成音效(simon polish round2,research/simon-polish/round1 取证 S2)。
新增 1 件(落 web/simon/audio/sfx/):
input_open 「答题窗口开放」标记音:sfx/input_open。confirm 震只标记"播放完毕",
占用预告/your_turn 语音链之后才真开放(_toInput);此前玩家作答被静默
吞(live-A 行 108-115)。该音为游戏层资产、不走向导层——guide=off /
iOS(无震动,00 §2.5)下仍有听觉边界。与 6 个手势音色(弹拨/琶音/底鼓/
木琴双响)及向导 turn earcon(1318.5Hz 单音)错开:双音上行柔铃。
one-shot QC(同 gen_space_syn.py 标准):去直流 + 首尾 8ms cos² + 真峰值 ≤-3dBFS
(编码后 ebur128 实测,超限自动降档重编)+ 编码后解码回读频谱。
用法:.venv-tts/bin/python tools/gen_simon_syn.py
"""
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_simon_syn_wav")
DST = os.path.join(ROOT, "web", "simon", "audio", "sfx")
os.makedirs(OUT, exist_ok=True)
os.makedirs(DST, exist_ok=True)
sr = 44100
ONE_SHOT_TP_CEIL = -3.0
SAMPLE_PEAK = 0.66
FADE_N = int(sr * 0.008)
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 bell(freq, dur, gain=1.0, shimmer=0.18):
"""柔铃单音:基频 + 弱二次泛音,快起双段缓释(同向导 earcon 包络思路)。"""
n = int(dur * sr)
t = np.arange(n) / sr
y = np.sin(2 * np.pi * freq * t) + shimmer * np.sin(2 * np.pi * freq * 2 * t)
env = np.minimum(1.0, t / 0.008)
env *= np.where(t < dur * 0.4,
np.exp(-t * 3.0),
np.exp(-dur * 0.4 * 3.0) * np.exp(-(t - dur * 0.4) * 9.0))
return y * env * gain
def input_open():
"""双音上行柔铃 B5→D6(0.35s):「开放作答」。不与 tone_doubletap(同音双响)
或 turn earcon(1318.5Hz 单音)同质。"""
y = np.zeros(int(sr * 0.38))
for at, f in [(0.0, 987.77), (0.14, 1174.66)]:
seg = bell(f, 0.3, gain=0.9)
n = min(len(seg), len(y) - int(at * sr))
y[int(at * sr):int(at * sr) + n] += seg[:n]
return dc(y)
ONE_SHOTS = {
"input_open": input_open,
}
# ---------------------------------------------------------------- 编码 + 真峰值校验
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 spectral_report(base):
"""编码后解码回读:≥3kHz/≥5kHz 能量占比(同 space polish 复测口径)。"""
r = subprocess.run([FFMPEG, "-v", "error", "-i", base + ".ogg", "-f", "f32le", "-ac", "1", "-ar", "44100", "-"],
capture_output=True)
y = np.frombuffer(r.stdout, dtype=np.float32)
Y = np.abs(np.fft.rfft(y * np.hanning(len(y))))
fr = np.fft.rfftfreq(len(y), 1 / sr)
e = Y ** 2
tot = e.sum() + 1e-12
return e[fr >= 3000].sum() / tot * 100, e[fr >= 5000].sum() / tot * 100, fr[np.argmax(Y)] / 1000
def main():
report = []
problems = []
for fid, fn in ONE_SHOTS.items():
wav_path = os.path.join(OUT, fid + ".wav")
base = os.path.join(DST, fid)
y = fade(norm(dc(fn())))
sf.write(wav_path, stereo(y), 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, stereo(y * gain), sr)
encode(wav_path, base)
tp2 = check_tp(base)
report.append((fid, len(y) / sr, tp, f"降 {20 * np.log10(gain):.1f}dB 重编 -> {tp2:.2f}"))
tp = tp2
else:
report.append((fid, len(y) / sr, tp, ""))
if tp > ONE_SHOT_TP_CEIL:
problems.append(f"{fid}: 真峰值 {tp:.2f} dBFS 仍超 {ONE_SHOT_TP_CEIL}")
print(f"{'id':14s} {'dur(s)':>7s} {'TP(dBFS)':>9s} {'%>3k':>7s} {'%>5k':>7s} {'peakkHz':>8s} note")
for fid, dur, tp, note in report:
e3, e5, pk = spectral_report(os.path.join(DST, fid))
print(f"{fid:14s} {dur:7.2f} {tp:9.2f} {e3:7.1f} {e5:7.1f} {pk:8.2f} {note}")
if e5 > 15.0:
problems.append(f"{fid}: 5kHz+ 能量 {e5:.1f}% 仍偏高")
if problems:
print("PROBLEMS:")
for p in problems:
print(" ", p)
return 1
print(f"ALL OK: {len(ONE_SHOTS)} one-shot -> {DST}")
return 0
if __name__ == "__main__":
sys.exit(main())