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 文档
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""节拍行者 — 音效合成(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")
|
||||
Reference in New Issue
Block a user