Files
SoundGame/web/rhythm/gen/gen_sfx.py
T

108 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""节拍行者 — 音效合成(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")