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:
SoundGame Dev
2026-08-29 21:55:44 +08:00
parent f0f0a9b50e
commit cc0f8c008d
1885 changed files with 11246 additions and 5 deletions
+107
View File
@@ -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")