#!/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/.wav(44.1kHz 立体声); 成品 → web/rhythm/audio/sfx/.{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())