Files
SoundGame/tools/gen_rhythm_syn.py
T
xsl ae850a4ce0 feat(rhythm): 鼓手模式重构——屏幕六区鼓组 + 无鼓伴奏合拍(用户裁定重构)
- 屏幕3列×2行六区鼓组(列=声像左中右/行=音高上亮下沉),六件程序合成打击乐,三重冗余蒙眼可辨
- 无鼓伴奏BGM上跟随领奏 call&response + 加花句;3曲90/105/120 BPM解锁制,BPM实测89.99/105.00/120.01
- 引擎后向兼容扩展:onGesture补{x,y}坐标(单参回调零感知,15游戏+3引擎测试全量回归零失败)
- 判定简化:全部音符=分区点按零延迟,旧双击补偿/下滑折中退役;键盘Digit1-6+空格=家区
- 教学摸鼓认区(摸哪报哪,8s无触摸自动进阶)+重教≤2+降速DDA+归因真话律
- 保留旧作好评:跟随领奏核心/S~C评级/解锁制/判定口径/guide工程遗产
- 语音49id双性别+8key;R1+R2连续2轮4/4愿意玩P0=0裁定可交付;live直证33 Perfect dt≈0.00s
- 03号文档仅文末加取代指针;新设计24号;档案research/rhythm-drummer/;Mureka累计$1.35
2026-09-14 20:17:10 +08:00

206 lines
7.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 -*-
"""《节拍行者》鼓手模式程序合成打击乐(24-rhythm-drummer.md §9.1,7 件 SYN)。
六区鼓组 + Perfect accent:kick/snare/hat/tomhi/crash/tomlo/accent。
落盘:staging wav → tools/gen_rhythm_syn_wav/<id>.wav(44.1kHz 立体声);
成品 → web/rhythm/audio/sfx/<id>.{ogg,m4a}(tools/bin/ffmpeg 双格式)。
QC(gen_cipher_syn.py 同款):去直流 + 首尾 8ms cos² 淡化 + 编码后解码回读
真峰值 ≤ -3dBFS(MDCT 过冲坑:ebur128 peak=true 实测,超限自动降档重编)。
打击乐起音必须硬(盲玩靠起音对拍);包络用 (1-exp(-t*k)) 快起沿。
用法:.venv-tts/bin/python tools/gen_rhythm_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_rhythm_syn_wav")
DST = os.path.join(ROOT, "web", "rhythm", "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)
_rng = np.random.default_rng(20260914)
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 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 sweep(n, f0, f1, dec):
"""指数扫频正弦(鼓皮),dec = 幅度衰减系数(1/s)。"""
t = np.arange(n) / sr
k = np.log(f1 / f0) / (n / sr)
ph = 2 * np.pi * f0 * (np.exp(k * t) - 1) / k
return np.sin(ph) * np.exp(-t * dec)
# ---------------------------------------------------------------- 鼓件渲染
def r_kick(): # 底鼓 BC:130→45Hz 扫频 + 触击瞬态(起沿 <3ms);id 沿用 inst_kick
n = int(sr * 0.28)
t = np.arange(n) / sr
y = sweep(n, 130, 45, 16) * 1.0
click = bp_noise(n, 1500, 6000) * np.exp(-t * 220) * 0.5 # 槌击瞬态
th = np.sin(2 * np.pi * 60 * t) * np.exp(-t * 22) * 0.35 # 低频胸口感
return dc((y + click + th) * (1 - np.exp(-t * 900)))
def r_snare(): # 军鼓 BL:200Hz 皮 + 1-4kHz 带通沙噪 + 钢丝噪
n = int(sr * 0.22)
t = np.arange(n) / sr
head = np.sin(2 * np.pi * 196 * t) * np.exp(-t * 38) * 0.8
wires = bp_noise(n, 3000, 7000) * np.exp(-t * 55) * 0.55
body = bp_noise(n, 800, 3000) * np.exp(-t * 30) * 0.7
return dc((head + wires + body) * (1 - np.exp(-t * 1200)))
def r_hat(): # 闭合踩镲 TL:6-10kHz 高通金属噪,60ms 急收
n = int(sr * 0.07)
t = np.arange(n) / sr
y = bp_noise(n, 6000, 12000) * np.exp(-t * 90)
y += bp_noise(n, 9000, 14000) * np.exp(-t * 140) * 0.6
metallic = (np.sin(2 * np.pi * 11200 * t) * 0.25
+ np.sin(2 * np.pi * 8350 * t) * 0.18) * np.exp(-t * 110)
return dc((y + metallic) * (1 - np.exp(-t * 2500)))
def r_tomhi(): # 高音嗵 TC:200→140Hz 皮,短促圆头
n = int(sr * 0.30)
t = np.arange(n) / sr
y = sweep(n, 205, 140, 14) * 0.95
skin = bp_noise(n, 300, 1400) * np.exp(-t * 40) * 0.3
return dc((y + skin) * (1 - np.exp(-t * 700)))
def r_crash(): # 吊镲 TR:4-12kHz 噪 + 非谐波泛音列,1.2s 长尾
n = int(sr * 1.30)
t = np.arange(n) / sr
y = bp_noise(n, 4000, 13000) * np.exp(-t * 5.5) * 0.8
for f, g in [(6120, 0.30), (8130, 0.22), (10460, 0.15), (4620, 0.20)]:
y += np.sin(2 * np.pi * f * t) * np.exp(-t * 4.2) * g
y += bp_noise(n, 2000, 5000) * np.exp(-t * 9) * 0.35
return dc(y * (1 - np.exp(-t * 1800)))
def r_tomlo(): # 低音嗵 BR:120→75Hz 皮,共鸣更久
n = int(sr * 0.35)
t = np.arange(n) / sr
y = sweep(n, 122, 75, 10) * 0.95
skin = bp_noise(n, 200, 900) * np.exp(-t * 30) * 0.3
return dc((y + skin) * (1 - np.exp(-t * 550)))
def r_accent(): # Perfect 叠加亮音:木鱼 1.5kHz + 2.2kHz 短双音
a = np.sin(2 * np.pi * 1500 * np.arange(int(sr * 0.05)) / sr) \
* np.exp(-np.arange(int(sr * 0.05)) / sr * 90)
b = np.sin(2 * np.pi * 2200 * np.arange(int(sr * 0.08)) / sr) \
* np.exp(-np.arange(int(sr * 0.08)) / sr * 60) * 0.7
return dc(np.concatenate([a, np.zeros(int(sr * 0.015)), b, np.zeros(int(sr * 0.03))]))
ONE_SHOTS = {
"inst_kick": r_kick,
"dm_snare": r_snare,
"dm_hat": r_hat,
"dm_tomhi": r_tomhi,
"dm_crash": r_crash,
"dm_tomlo": r_tomlo,
"dm_accent": r_accent,
}
# ---------------------------------------------------------------- 编码 + 真峰值校验
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 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: # MDCT 过冲超限:按超出量降档重编一次再验
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':12s} {'dur(s)':>7s} {'TP(dBFS)':>9s} note")
for fid, dur, tp, note in report:
print(f"{fid:12s} {dur:7.2f} {tp:9.2f} {note}")
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())