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,48 @@
|
||||
"""资产清单校验:game.js 引用的每个音频 id 都必须有 .ogg 和 .m4a。
|
||||
|
||||
- 提取所有 'bgm/xxx' 'voice/xxx' 'sfx/xxx' 字面量 id;
|
||||
- numIds() 里动态拼的 voice/num_${k}(k=0..9)特判展开。
|
||||
用法:python3 gen/check_assets.py
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
GAME_DIR = os.path.normpath(os.path.join(HERE, ".."))
|
||||
AUDIO = os.path.join(GAME_DIR, "audio")
|
||||
|
||||
src = ""
|
||||
for js in ("game.js", "index.html"):
|
||||
with open(os.path.join(GAME_DIR, js), encoding="utf-8") as f:
|
||||
src += f.read()
|
||||
|
||||
ids = set(re.findall(r"['\"]((?:bgm|voice|sfx)/[a-z0-9_]+)['\"]", src))
|
||||
|
||||
# 动态拼接的数字语音 voice/num_0 .. voice/num_9
|
||||
if re.search(r"voice/num_\$\{", src) or "voice/num_" in src:
|
||||
ids.update(f"voice/num_{k}" for k in range(10))
|
||||
|
||||
missing = []
|
||||
for aid in sorted(ids):
|
||||
for ext in (".ogg", ".m4a"):
|
||||
if not os.path.isfile(os.path.join(AUDIO, aid + ext)):
|
||||
missing.append(aid + ext)
|
||||
|
||||
print(f"引用音频 id 数: {len(ids)}")
|
||||
if missing:
|
||||
print("缺失文件:")
|
||||
for m in missing:
|
||||
print(" ", m)
|
||||
sys.exit(1)
|
||||
|
||||
# 反向检查:目录里有但代码没引用的资产(提示,不失败)
|
||||
unreferenced = []
|
||||
for layer in ("bgm", "voice", "sfx"):
|
||||
d = os.path.join(AUDIO, layer)
|
||||
for f in os.listdir(d):
|
||||
if f.endswith(".ogg") and f"{layer}/{f[:-4]}" not in ids:
|
||||
unreferenced.append(f"{layer}/{f}")
|
||||
if unreferenced:
|
||||
print("未被引用的资产(提示):", ", ".join(sorted(unreferenced)))
|
||||
print("OK:全部引用 id 均有 .ogg 与 .m4a")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""web/simon 封面:1080x1920,深色底 + 游戏名 + 玩法提示 + 手势图例。"""
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
W, H = 1080, 1920
|
||||
FONT = "/home/ubuntu/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf"
|
||||
OUT = os.path.join(os.path.dirname(__file__), "..", "cover.png")
|
||||
|
||||
img = Image.new("RGB", (W, H), (10, 12, 26))
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 背景:竖向微渐变 + 随机声波弧线
|
||||
for y in range(H):
|
||||
t = y / H
|
||||
d.line([(0, y), (W, y)], fill=(int(10 + 14 * t), int(12 + 10 * t), int(26 + 30 * t)))
|
||||
|
||||
wave = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
wd = ImageDraw.Draw(wave)
|
||||
rng = random.Random(42)
|
||||
colors = [(94, 234, 212), (129, 140, 248), (244, 114, 182), (250, 204, 21)]
|
||||
for i in range(28):
|
||||
cx, cy = rng.randint(0, W), rng.randint(0, H)
|
||||
r = rng.randint(60, 320)
|
||||
c = colors[i % len(colors)]
|
||||
a = rng.randint(10, 36)
|
||||
wd.ellipse([cx - r, cy - r, cx + r, cy + r], outline=c + (a,), width=rng.randint(2, 5))
|
||||
wave = wave.filter(ImageFilter.GaussianBlur(1.5))
|
||||
img.paste(wave, (0, 0), wave)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 中央:六边形"声波脉冲"标志
|
||||
cx, cy = W // 2, 640
|
||||
pulse_cols = [(94, 234, 212), (129, 140, 248), (244, 114, 182)]
|
||||
for i, r in enumerate([120, 200, 280]):
|
||||
col = pulse_cols[i]
|
||||
alpha = 200 - i * 55
|
||||
ring = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
rd = ImageDraw.Draw(ring)
|
||||
rd.ellipse([cx - r, cy - r, cx + r, cy + r], outline=col + (alpha,), width=10 - i * 2)
|
||||
img.paste(ring, (0, 0), ring)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.ellipse([cx - 52, cy - 52, cx + 52, cy + 52], fill=(94, 234, 212))
|
||||
|
||||
# 标题
|
||||
f_title = ImageFont.truetype(FONT, 190)
|
||||
f_sub = ImageFont.truetype(FONT, 56)
|
||||
f_small = ImageFont.truetype(FONT, 40)
|
||||
|
||||
def ctext(y, s, font, fill, spacing=0):
|
||||
bbox = d.textbbox((0, 0), s, font=font)
|
||||
w = bbox[2] - bbox[0]
|
||||
x = (W - w) / 2
|
||||
for ch in s:
|
||||
d.text((x, y), ch, font=font, fill=fill)
|
||||
bb = d.textbbox((0, 0), ch, font=font)
|
||||
x += (bb[2] - bb[0]) + spacing
|
||||
|
||||
ctext(980, "音序回响", f_title, (240, 244, 255), spacing=12)
|
||||
ctext(1230, "听音辨序 · 复现手势", f_sub, (148, 163, 184))
|
||||
|
||||
# 手势图例(两行三列)
|
||||
hints = ["↑ 上滑 上行琶音", "↓ 下滑 下行低音", "← 左滑 左侧声道",
|
||||
"→ 右滑 右侧声道", "· 单击 短促鼓点", "·· 双击 两声连响"]
|
||||
hx, hy = [60, 370, 680], 1400
|
||||
for i, s in enumerate(hints):
|
||||
col = i % 3
|
||||
row = i // 3
|
||||
d.text((hx[col], hy + row * 84), s, font=f_small, fill=(110, 130, 160))
|
||||
|
||||
ctext(1700, "从 2 个音开始,每轮 +1,看你能坚持几轮", f_small, (90, 105, 135))
|
||||
|
||||
img.save(OUT)
|
||||
print(OUT, img.size)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""web/simon SFX 合成(numpy,44.1kHz 立体声 wav,后续 ffmpeg 转 ogg/m4a)。
|
||||
|
||||
6 种手势特征音 + 4 个反馈音:
|
||||
tone_up 上行琶音 C5-E5-G5-C6(明亮弹拨)
|
||||
tone_down 下行低音 A2-F2-D2-A1(浑厚)
|
||||
tone_left E4 弹拨,声像极左
|
||||
tone_right G4 弹拨,声像极右
|
||||
tone_tap 短促底鼓
|
||||
tone_doubletap 两声连响(木琴质感)
|
||||
correct 上行双音和弦(答对)
|
||||
wrong 失谐锯齿低鸣(答错)
|
||||
newrecord 快速号角琶音(新纪录)
|
||||
gameover 下行三音(游戏结束)
|
||||
"""
|
||||
import os
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
SR = 44100
|
||||
OUT = os.path.join(os.path.dirname(__file__), "..", "audio", "sfx", "wav_tmp")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def env_adsr(n, a=0.005, d=0.05, s=0.3, r=0.08):
|
||||
"""a/d/r 为秒,s 为 sustain 电平(0~1)。"""
|
||||
na, nd, nr = int(a * SR), int(d * SR), int(r * SR)
|
||||
ns = max(0, n - na - nd - nr)
|
||||
e = np.concatenate([
|
||||
np.linspace(0, 1, na, endpoint=False),
|
||||
np.linspace(1, s, nd, endpoint=False),
|
||||
np.full(ns, s),
|
||||
np.linspace(s, 0, nr),
|
||||
])
|
||||
return e[:n] if len(e) >= n else np.pad(e, (0, n - len(e)))
|
||||
|
||||
|
||||
def pluck(freq, dur, brightness=4, decay=6.0):
|
||||
"""类弹拨/木琴:基频 + 递减泛音,指数衰减。"""
|
||||
n = int(dur * SR)
|
||||
t = np.arange(n) / SR
|
||||
sig = np.zeros(n)
|
||||
for h in range(1, brightness + 1):
|
||||
sig += (1.0 / h) * np.sin(2 * np.pi * freq * h * t)
|
||||
sig *= np.exp(-decay * t)
|
||||
return sig
|
||||
|
||||
|
||||
def kick(dur=0.18):
|
||||
"""底鼓:正弦扫频 150→45Hz + 起始咔哒噪声。"""
|
||||
n = int(dur * SR)
|
||||
t = np.arange(n) / SR
|
||||
f = 45 + (150 - 45) * np.exp(-30 * t)
|
||||
phase = 2 * np.pi * np.cumsum(f) / SR
|
||||
sig = np.sin(phase) * np.exp(-18 * t)
|
||||
click = np.random.default_rng(7).standard_normal(n) * np.exp(-400 * t) * 0.3
|
||||
return sig + click
|
||||
|
||||
|
||||
def saw(freq, dur, detune=1.01):
|
||||
n = int(dur * SR)
|
||||
t = np.arange(n) / SR
|
||||
def s(f):
|
||||
# 加法合成锯齿(前 12 次泛音)
|
||||
out = np.zeros(n)
|
||||
for h in range(1, 13):
|
||||
out += (1.0 / h) * np.sin(2 * np.pi * f * h * t)
|
||||
return out
|
||||
sig = (s(freq) + s(freq * detune)) * 0.5
|
||||
return sig * env_adsr(n, a=0.01, d=0.1, s=0.6, r=0.15)
|
||||
|
||||
|
||||
def stereo(mid, pan=0.0):
|
||||
"""pan: -1 极左 ~ +1 极右。"""
|
||||
mid = mid / (np.max(np.abs(mid)) + 1e-9) * 0.85
|
||||
l = mid * np.sqrt((1 - pan) / 2) * np.sqrt(2)
|
||||
r = mid * np.sqrt((1 + pan) / 2) * np.sqrt(2)
|
||||
return np.stack([l, r], axis=1)
|
||||
|
||||
|
||||
def seq(notes, gap=0.0, **kw):
|
||||
"""音符序列拼接:notes = [(freq, dur), ...],gap 为音符间隔秒。"""
|
||||
parts = []
|
||||
gapbuf = np.zeros(int(gap * SR))
|
||||
for f, d in notes:
|
||||
parts.append(pluck(f, d, **kw))
|
||||
parts.append(gapbuf)
|
||||
return np.concatenate(parts[:-1]) if parts else np.zeros(1)
|
||||
|
||||
|
||||
def save(name, sig):
|
||||
sig = sig / (np.max(np.abs(sig)) + 1e-9) * 0.85
|
||||
if sig.ndim == 1:
|
||||
sig = np.stack([sig, sig], axis=1)
|
||||
path = os.path.join(OUT, name + ".wav")
|
||||
sf.write(path, sig, SR)
|
||||
print(path, f"{len(sig)/SR:.2f}s")
|
||||
|
||||
|
||||
# ---- 6 手势音 ----
|
||||
save("tone_up", stereo(seq([(523.25, .14), (659.25, .14), (783.99, .14), (1046.50, .30)], decay=8)))
|
||||
save("tone_down", stereo(seq([(110.0, .18), (87.31, .18), (73.42, .18), (55.0, .40)], brightness=3, decay=5)))
|
||||
save("tone_left", stereo(pluck(329.63, 0.4, decay=7), pan=-0.93))
|
||||
save("tone_right", stereo(pluck(392.0, 0.4, decay=7), pan=0.93))
|
||||
save("tone_tap", stereo(kick()))
|
||||
dbl = np.concatenate([pluck(880, 0.10, decay=14), np.zeros(int(0.09 * SR)), pluck(1174.66, 0.16, decay=12)])
|
||||
save("tone_doubletap", stereo(dbl))
|
||||
|
||||
# ---- 反馈音 ----
|
||||
save("correct", stereo(seq([(783.99, .14), (1318.51, .30)], decay=9)))
|
||||
save("wrong", stereo(saw(110, 0.6)))
|
||||
save("newrecord", stereo(seq([(523.25, .10), (659.25, .10), (783.99, .10), (1046.50, .10), (1318.51, .35)], decay=8)))
|
||||
save("gameover", stereo(seq([(392.0, .22), (311.13, .22), (246.94, .5)], brightness=3, decay=5)))
|
||||
@@ -0,0 +1,72 @@
|
||||
"""web/simon 语音批量生成(Qwen3-TTS CustomVoice,Serena 音色)。
|
||||
|
||||
生成后写入 audio/voice/wav_tmp/*.wav,由 shell 统一 ffmpeg 转 ogg/m4a。
|
||||
每条断言:时长 > 0.3s(不足时尾部补静音到 0.35s)、峰值 > 0.05(统一峰值归一 0.8)。
|
||||
"""
|
||||
import os
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
OUT = os.path.join(os.path.dirname(__file__), "..", "audio", "voice", "wav_tmp")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
LINES = {
|
||||
# 开场 + 教学
|
||||
"intro": "欢迎来到音序回响。我会用六种声音演奏一段序列,请你记住顺序,再用同样的手势复现出来。先来认识这六种声音。",
|
||||
"teach_up": "向上滑,是上行琶音。",
|
||||
"teach_down": "向下滑,是低沉的下行音。",
|
||||
"teach_left": "向左滑,声音在你的左边。",
|
||||
"teach_right": "向右滑,声音在你的右边。",
|
||||
"teach_tap": "单击,是短促的鼓点。",
|
||||
"teach_doubletap": "双击,是两声连响。",
|
||||
"teach_end": "好了,记住这些声音。序列会越来越长,看你能坚持几轮。",
|
||||
# 流程提示
|
||||
"listen": "听好了。",
|
||||
"your_turn": "轮到你了。",
|
||||
# 反馈
|
||||
"correct": "答对了!",
|
||||
"wrong": "错了。",
|
||||
"new_record": "新纪录!",
|
||||
"gameover": "游戏结束。",
|
||||
"replay": "单击屏幕,再来一局。",
|
||||
# 播报拼接片段
|
||||
"score_pre": "你坚持了",
|
||||
"best_pre": "历史最高纪录,",
|
||||
"di": "第",
|
||||
"lun": "轮",
|
||||
"num_shi": "十",
|
||||
"num_0": "零", "num_1": "一", "num_2": "二", "num_3": "三", "num_4": "四",
|
||||
"num_5": "五", "num_6": "六", "num_7": "七", "num_8": "八", "num_9": "九",
|
||||
}
|
||||
|
||||
model = Qwen3TTSModel.from_pretrained(
|
||||
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
||||
device_map="cuda:0",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
names = list(LINES.keys())
|
||||
texts = [LINES[n] for n in names]
|
||||
wavs, sr = model.generate_custom_voice(
|
||||
text=texts,
|
||||
language=["Chinese"] * len(names),
|
||||
speaker="Serena",
|
||||
instruct="用清晰、沉稳而有引导感的声音,语速适中,像一个耐心的游戏主持人",
|
||||
)
|
||||
|
||||
for name, wav in zip(names, wavs):
|
||||
wav = np.asarray(wav, dtype=np.float32)
|
||||
peak = np.max(np.abs(wav))
|
||||
assert peak > 0.05, f"{name} 峰值过小: {peak}"
|
||||
wav = wav / peak * 0.8
|
||||
dur = len(wav) / sr
|
||||
if dur < 0.35: # 单字片段补静音,保证可听清且满足时长断言
|
||||
wav = np.concatenate([wav, np.zeros(int((0.35 - dur) * sr), dtype=np.float32)])
|
||||
dur = len(wav) / sr
|
||||
assert dur > 0.3, f"{name} 时长过短: {dur}"
|
||||
path = os.path.join(OUT, name + ".wav")
|
||||
sf.write(path, wav, sr)
|
||||
print(f"{name}: {dur:.2f}s peak={np.max(np.abs(wav)):.2f}")
|
||||
print("done", len(names), "clips")
|
||||
Reference in New Issue
Block a user