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
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# 把 audio/{voice,sfx} 下的 *.wav 转成 .ogg (libvorbis q3, 44.1k) + .m4a (aac 128k),
# 然后删除 wav。用法:bash gen_convert.sh
set -euo pipefail
cd "$(dirname "$0")/.."
for f in audio/voice/*.wav audio/sfx/*.wav; do
[ -e "$f" ] || continue
base="${f%.wav}"
ffmpeg -y -v error -i "$f" -ar 44100 -c:a libvorbis -q:a 3 "${base}.ogg"
ffmpeg -y -v error -i "$f" -ar 44100 -c:a aac -b:a 128k "${base}.m4a"
rm "$f"
echo "converted: $base"
done
+63
View File
@@ -0,0 +1,63 @@
"""节拍行者封面生成:1080x1920 深色底 + 游戏名 + 玩法提示。"""
import math
import os
from PIL import Image, ImageDraw, ImageFont
W, H = 1080, 1920
ROOT = "/home/ubuntu/SoundGame/web/rhythm"
FONT = os.path.join(ROOT, "gen", "NotoSansSC-Bold.ttf")
img = Image.new("RGB", (W, H), (10, 12, 24))
d = ImageDraw.Draw(img)
# 底部到中部渐亮
for y in range(H):
t = y / H
r = int(10 + 20 * t)
g = int(12 + 14 * t)
b = int(24 + 46 * t)
d.line([(0, y), (W, y)], fill=(r, g, b))
# 律动音柱装饰(模拟频谱柱,青/品红双色)
bars = 24
bw = W // bars
for i in range(bars):
h = int(120 + 260 * abs(math.sin(i * 1.7) * 0.6 + math.sin(i * 0.9) * 0.4))
color = (0, 229, 255) if i % 2 == 0 else (255, 64, 129)
x0 = i * bw + 8
d.rounded_rectangle([x0, H - 320 - h, x0 + bw - 16, H - 320], 10, fill=color)
# 顶部镜像倒影(暗一些)
dim = tuple(c // 3 for c in color)
d.rounded_rectangle([x0, 260, x0 + bw - 16, 260 + h // 2], 10, fill=dim)
# 节拍圆点(四四拍)
for i in range(4):
cx = W // 2 - 180 + i * 120
cy = H - 180
fill = (255, 215, 64) if i == 0 else (60, 66, 90)
d.ellipse([cx - 28, cy - 28, cx + 28, cy + 28], fill=fill)
f_title = ImageFont.truetype(FONT, 220)
f_sub = ImageFont.truetype(FONT, 56)
f_small = ImageFont.truetype(FONT, 40)
title = "节拍行者"
bbox = d.textbbox((0, 0), title, font=f_title)
tw = bbox[2] - bbox[0]
ty = H // 2 - 260
# 描边阴影 + 主字
d.text((W // 2 - tw // 2 + 6, ty + 8), title, font=f_title, fill=(0, 0, 0))
d.text((W // 2 - tw // 2, ty), title, font=f_title, fill=(240, 245, 255))
sub = "听音踩点 · 跟上节拍"
bbox = d.textbbox((0, 0), sub, font=f_sub)
d.text((W // 2 - (bbox[2] - bbox[0]) // 2, ty + 300), sub, font=f_sub, fill=(0, 229, 255))
hint = "滑上下左右 · 单击 · 双击"
bbox = d.textbbox((0, 0), hint, font=f_small)
d.text((W // 2 - (bbox[2] - bbox[0]) // 2, ty + 410), hint, font=f_small, fill=(150, 160, 190))
out = os.path.join(ROOT, "cover.png")
img.save(out)
print("saved", out, img.size)
+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")
+78
View File
@@ -0,0 +1,78 @@
"""节拍行者 — 语音对白生成(Qwen3-TTS CustomVoice)。
用法(必须 flock,GPU 与其他 agent 共享):
flock /home/ubuntu/SoundGame/.gpu.lock \
/home/ubuntu/SoundGame/.venv-tts/bin/python gen_voice.py
产出 web/rhythm/audio/voice/*.wav(24kHz),随后由 gen_convert.sh 转 ogg/m4a。
"""
import os
os.environ.setdefault("HF_HOME", "/home/ubuntu/SoundGame/models/hf")
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
import numpy as np
import soundfile as sf
import torch
from qwen_tts import Qwen3TTSModel
OUT = "/home/ubuntu/SoundGame/web/rhythm/audio/voice"
os.makedirs(OUT, exist_ok=True)
# (id, 文本, 情感指令)
LINES = [
("welcome",
"欢迎来到节拍行者!规则很简单:仔细听提示音,在下一个节拍,做出对应的动作。"
"建议用有线耳机或扬声器游玩,蓝牙耳机会有延迟。",
"热情活力的游戏解说语气"),
("rules",
"听好了:上行音,向上滑;下行音,向下滑;低音,向左滑;高音,向右滑;"
"一声轻响,单击;两声轻响,快速双击。",
"清晰利落的教学语气,节奏感强"),
("tut_start",
"先来四小节练习。跟着节拍器,听到提示音,就在下一拍做动作。大胆做!",
"鼓励的语气"),
("tut_done",
"练习结束!正式挑战开始,一共四十个指令。预备!",
"兴奋、充满干劲的语气"),
("grade_s",
"评级,S!完美无缺,你就是节拍大师!",
"极度兴奋、欢呼的语气"),
("grade_a",
"评级,A!非常出色,差一点就完美了!",
"开心赞许的语气"),
("grade_b",
"评级,B。不错不错,继续加油!",
"温和鼓励的语气"),
("grade_c",
"评级,C。别灰心,多听节拍,你一定行!",
"安慰打气的语气"),
("restart",
"单击屏幕,再来一局。",
"轻松愉快的语气"),
]
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
device_map="cuda:0",
dtype=torch.bfloat16,
)
wavs, sr = model.generate_custom_voice(
text=[t for _, t, _ in LINES],
language=["Chinese"] * len(LINES),
speaker=["Vivian"] * len(LINES),
instruct=[i for _, _, i in LINES],
)
for (vid, text, _), wav in zip(LINES, wavs):
wav = np.asarray(wav, dtype=np.float32)
dur = len(wav) / sr
peak = float(np.max(np.abs(wav))) if len(wav) else 0.0
assert dur > 0.3, f"{vid}: 时长异常 {dur:.2f}s"
assert peak > 0.05, f"{vid}: 峰值异常 {peak:.3f}"
path = os.path.join(OUT, vid + ".wav")
sf.write(path, wav, sr)
print(f"{vid}: {dur:.2f}s peak={peak:.3f} -> {path}")
print("ALL OK")