"""节拍行者 — 音效合成(numpy 纯合成,不用 GPU)。 产出 web/rhythm/audio/sfx/*.wav(44.1kHz mono),随后由 gen_convert.sh 转 ogg/m4a。 指令提示音设计(6 种,听感区分度优先): cue_up 上行双音 E5->A5(正弦,明亮上扬) → 上滑 cue_down 下行双音 A5->E5(正弦,低沉下坠) → 下滑 cue_left 单发低音 G4(三角波,浑厚) → 左滑 cue_right 单发高音 G5(三角波,清脆) → 右滑 cue_tap 单声短促 ping E6 → 单击 cue_double 双声短促 ping E6(间隔 100ms) → 双击 节拍器 / 判定反馈: tick / tick_accent 节拍器咔哒声(弱拍 / 强拍) judge_perfect 明亮风铃琶音 judge_good 柔和单音 judge_miss 低闷 buzz """ import os import numpy as np import soundfile as sf SR = 44100 OUT = "/home/ubuntu/SoundGame/web/rhythm/audio/sfx" os.makedirs(OUT, exist_ok=True) def env(n, attack=0.004): """起音 attack 秒 + 指数衰减到 -60dB 的包络。""" a = max(1, int(attack * SR)) e = np.ones(n) e[:a] = np.linspace(0, 1, a) tail = np.exp(np.linspace(0, -6.9, n - a)) # 到末尾衰减约 60dB e[a:] = tail return e def tone(freq, dur, wave="sine", attack=0.004): n = int(dur * SR) t = np.arange(n) / SR ph = 2 * np.pi * freq * t if wave == "sine": x = np.sin(ph) elif wave == "tri": x = 2 * np.abs(2 * ((ph / (2 * np.pi)) % 1) - 1) - 1 elif wave == "square": x = np.sign(np.sin(ph)) else: raise ValueError(wave) return x * env(n, attack) def seq(notes, gap=0.0, wave="sine"): """把若干 (freq, dur) 音符按 gap 间隔拼接。""" parts = [] for f, d in notes: parts.append(tone(f, d, wave)) if gap > 0: parts.append(np.zeros(int(gap * SR))) return np.concatenate(parts) def click(freq, dur, decay_ms): n = int(dur * SR) t = np.arange(n) / SR x = np.sin(2 * np.pi * freq * t) * np.exp(-t / (decay_ms / 1000)) return x def norm(x, peak=0.7): m = np.max(np.abs(x)) return x / m * peak if m > 0 else x SFX = { # 指令提示音 "cue_up": seq([(659.25, 0.09), (880.0, 0.12)]), # E5 -> A5 上行 "cue_down": seq([(880.0, 0.09), (659.25, 0.12)]), # A5 -> E5 下行 "cue_left": norm(tone(392.0, 0.16, "tri")), # G4 低音 "cue_right": norm(tone(783.99, 0.16, "tri")), # G5 高音 "cue_tap": norm(click(1567.98, 0.06, 12)), # E6 单响 "cue_double": norm(np.concatenate([ # E6 双响 click(1567.98, 0.05, 12), np.zeros(int(0.05 * SR)), click(1567.98, 0.05, 12), ])), # 节拍器 "tick": norm(click(2093.0, 0.03, 6), 0.35), # 弱拍 "tick_accent": norm(click(1760.0, 0.045, 10), 0.5), # 强拍 # 判定反馈 "judge_perfect": norm(np.concatenate([ # 明亮琶音 E6 G#6 B6 tone(1318.51, 0.07), tone(1661.22, 0.07), tone(1975.53, 0.16), ]) * 0.9, 0.65), "judge_good": norm(tone(880.0, 0.12), 0.5), # 柔和 A5 "judge_miss": norm(tone(110.0, 0.22, "square", attack=0.002), 0.45), # 低闷 buzz } # cue 序列类再做整体归一(seq 内部未归一) for k in ("cue_up", "cue_down"): SFX[k] = norm(SFX[k]) for name, x in SFX.items(): path = os.path.join(OUT, name + ".wav") sf.write(path, x.astype(np.float32), SR) print(f"{name}: {len(x)/SR:.3f}s peak={np.max(np.abs(x)):.2f} -> {path}") print("ALL OK")