- 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无离群)
156 lines
5.5 KiB
Python
156 lines
5.5 KiB
Python
#!/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())
|