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:
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user