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
This commit is contained in:
xsl
2026-09-14 20:17:10 +08:00
parent ddada6980c
commit ae850a4ce0
280 changed files with 5671 additions and 1121 deletions
+205
View File
@@ -0,0 +1,205 @@
#!/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())