"""web/simon SFX 合成(numpy,44.1kHz 立体声 wav,后续 ffmpeg 转 ogg/m4a)。 6 种手势特征音 + 4 个反馈音: tone_up 上行琶音 C5-E5-G5-C6(明亮弹拨) tone_down 下行低音 A2-F2-D2-A1(浑厚) tone_left E4 弹拨,声像极左 tone_right G4 弹拨,声像极右 tone_tap 短促底鼓 tone_doubletap 两声连响(木琴质感) correct 上行双音和弦(答对) wrong 失谐锯齿低鸣(答错) newrecord 快速号角琶音(新纪录) gameover 下行三音(游戏结束) """ import os import numpy as np import soundfile as sf SR = 44100 OUT = os.path.join(os.path.dirname(__file__), "..", "audio", "sfx", "wav_tmp") os.makedirs(OUT, exist_ok=True) def env_adsr(n, a=0.005, d=0.05, s=0.3, r=0.08): """a/d/r 为秒,s 为 sustain 电平(0~1)。""" na, nd, nr = int(a * SR), int(d * SR), int(r * SR) ns = max(0, n - na - nd - nr) e = np.concatenate([ np.linspace(0, 1, na, endpoint=False), np.linspace(1, s, nd, endpoint=False), np.full(ns, s), np.linspace(s, 0, nr), ]) return e[:n] if len(e) >= n else np.pad(e, (0, n - len(e))) def pluck(freq, dur, brightness=4, decay=6.0): """类弹拨/木琴:基频 + 递减泛音,指数衰减。""" n = int(dur * SR) t = np.arange(n) / SR sig = np.zeros(n) for h in range(1, brightness + 1): sig += (1.0 / h) * np.sin(2 * np.pi * freq * h * t) sig *= np.exp(-decay * t) return sig def kick(dur=0.18): """底鼓:正弦扫频 150→45Hz + 起始咔哒噪声。""" n = int(dur * SR) t = np.arange(n) / SR f = 45 + (150 - 45) * np.exp(-30 * t) phase = 2 * np.pi * np.cumsum(f) / SR sig = np.sin(phase) * np.exp(-18 * t) click = np.random.default_rng(7).standard_normal(n) * np.exp(-400 * t) * 0.3 return sig + click def saw(freq, dur, detune=1.01): n = int(dur * SR) t = np.arange(n) / SR def s(f): # 加法合成锯齿(前 12 次泛音) out = np.zeros(n) for h in range(1, 13): out += (1.0 / h) * np.sin(2 * np.pi * f * h * t) return out sig = (s(freq) + s(freq * detune)) * 0.5 return sig * env_adsr(n, a=0.01, d=0.1, s=0.6, r=0.15) def stereo(mid, pan=0.0): """pan: -1 极左 ~ +1 极右。""" mid = mid / (np.max(np.abs(mid)) + 1e-9) * 0.85 l = mid * np.sqrt((1 - pan) / 2) * np.sqrt(2) r = mid * np.sqrt((1 + pan) / 2) * np.sqrt(2) return np.stack([l, r], axis=1) def seq(notes, gap=0.0, **kw): """音符序列拼接:notes = [(freq, dur), ...],gap 为音符间隔秒。""" parts = [] gapbuf = np.zeros(int(gap * SR)) for f, d in notes: parts.append(pluck(f, d, **kw)) parts.append(gapbuf) return np.concatenate(parts[:-1]) if parts else np.zeros(1) def save(name, sig): sig = sig / (np.max(np.abs(sig)) + 1e-9) * 0.85 if sig.ndim == 1: sig = np.stack([sig, sig], axis=1) path = os.path.join(OUT, name + ".wav") sf.write(path, sig, SR) print(path, f"{len(sig)/SR:.2f}s") # ---- 6 手势音 ---- save("tone_up", stereo(seq([(523.25, .14), (659.25, .14), (783.99, .14), (1046.50, .30)], decay=8))) save("tone_down", stereo(seq([(110.0, .18), (87.31, .18), (73.42, .18), (55.0, .40)], brightness=3, decay=5))) save("tone_left", stereo(pluck(329.63, 0.4, decay=7), pan=-0.93)) save("tone_right", stereo(pluck(392.0, 0.4, decay=7), pan=0.93)) save("tone_tap", stereo(kick())) dbl = np.concatenate([pluck(880, 0.10, decay=14), np.zeros(int(0.09 * SR)), pluck(1174.66, 0.16, decay=12)]) save("tone_doubletap", stereo(dbl)) # ---- 反馈音 ---- save("correct", stereo(seq([(783.99, .14), (1318.51, .30)], decay=9))) save("wrong", stereo(saw(110, 0.6))) save("newrecord", stereo(seq([(523.25, .10), (659.25, .10), (783.99, .10), (1046.50, .10), (1318.51, .35)], decay=8))) save("gameover", stereo(seq([(392.0, .22), (311.13, .22), (246.94, .5)], brightness=3, decay=5)))