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:
@@ -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())
|
||||
Reference in New Issue
Block a user