Files
SoundGame/web/simon/gen/gen_sfx.py
T
SoundGame Dev cc0f8c008d HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构
- 新增 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 文档
2026-08-29 21:55:44 +08:00

113 lines
4.0 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.
"""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)))