Files
SoundGame/web/rhythm/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

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/ubuntu/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")