- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、 Node 零依赖服务器(静态 + 注册登录 + 进度存档 API) - 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、 节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除 - 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、 震动反馈语言(10 种震型)、服务器进度同步 - 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、 Mureka BGM 4+3 首),终检零缺失 - 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、 web/server/README.md API 文档
113 lines
4.0 KiB
Python
113 lines
4.0 KiB
Python
"""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)))
|