108 lines
3.7 KiB
Python
108 lines
3.7 KiB
Python
"""节拍行者 — 音效合成(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/xsl/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")
|