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
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""实测音频 BPM 并找出首个强拍起点(librosa)。
用法: .venv-tts/bin/python tools/bpm_check.py <audio> <target_bpm>
输出 JSON: {"measured_bpm": ..., "octave_ok": bool, "first_beat_sec": ..., "n_beats": ...}
octave_ok: 实测值与目标偏差 <=2%(含半速/倍速的节拍八度等价)。
"""
import json
import sys
import librosa
import numpy as np
path, target = sys.argv[1], float(sys.argv[2])
y, sr = librosa.load(path, sr=22050, mono=True)
onset = librosa.onset.onset_strength(y=y, sr=sr)
tempo, beats = librosa.beat.beat_track(y=y, sr=sr, onset_envelope=onset, start_bpm=target)
tempo = float(np.atleast_1d(tempo)[0])
def dev(a, b):
return abs(a - b) / b
ok = any(dev(tempo, target * k) <= 0.02 for k in (0.5, 1.0, 2.0))
beat_times = librosa.frames_to_time(beats, sr=sr)
first_beat = 0.0
if len(beat_times):
# 第一个 onset 强度足够高的拍(跳过 intro 弱起)
thresh = 0.4 * float(np.max(onset))
for bf, bt in zip(beats, beat_times):
if bf < len(onset) and onset[bf] >= thresh:
first_beat = float(bt)
break
else:
first_beat = float(beat_times[0])
print(json.dumps({
"file": path,
"measured_bpm": round(tempo, 2),
"target": target,
"deviation_pct": round(dev(tempo, target) * 100, 2),
"octave_ok": ok,
"first_beat_sec": round(first_beat, 3),
"n_beats": int(len(beats)),
}))
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""高精度 BPM 实测:onset 包络 + 梳状滤波扫描(分数精度,亚帧分辨率)。
用法: .venv-tts/bin/python tools/bpm_precise.py <audio> [bpm_min] [bpm_max]
输出 JSON: {"bpm": ..., "score": ..., "harmonics": {...}}
"""
import json
import sys
import librosa
import numpy as np
path = sys.argv[1]
bpm_lo = float(sys.argv[2]) if len(sys.argv) > 2 else 70.0
bpm_hi = float(sys.argv[3]) if len(sys.argv) > 3 else 140.0
y, sr = librosa.load(path, sr=22050, mono=True)
onset = librosa.onset.onset_strength(y=y, sr=sr)
onset = onset - onset.mean()
fps = sr / 512.0 # hop 512
def comb_score(bpm):
period = fps * 60.0 / bpm
n_teeth = int((len(onset) - 1) / period)
if n_teeth < 16:
return 0.0
# 相位扫描取最优
best = 0.0
for phase in np.linspace(0, period, 24, endpoint=False):
idx = np.round(phase + period * np.arange(n_teeth)).astype(int)
idx = idx[idx < len(onset)]
s = float(onset[idx].sum()) / len(idx)
if s > best:
best = s
return best
cands = np.arange(bpm_lo, bpm_hi, 0.05)
scores = np.array([comb_score(b) for b in cands])
best_i = int(np.argmax(scores))
best_bpm = float(cands[best_i])
# 抛物线插值细化
if 0 < best_i < len(cands) - 1:
y0, y1, y2 = scores[best_i - 1], scores[best_i], scores[best_i + 1]
denom = (y0 - 2 * y1 + y2)
if abs(denom) > 1e-12:
best_bpm += 0.05 * 0.5 * (y0 - y2) / denom
harm = {}
for k, name in ((0.5, "half"), (2.0, "double")):
b = best_bpm * k
if bpm_lo <= b <= bpm_hi:
harm[name] = round(float(scores[np.argmin(np.abs(cands - b))]), 4)
print(json.dumps({
"file": path,
"bpm": round(best_bpm, 2),
"score": round(float(scores[best_i]), 4),
"harmonics": harm,
}))
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全项目音频资产终检(R5)。
检查 6 个游戏 web/<game>/audio/:
1. 每个 .ogg 都有同名 .m4a(反之亦然)
2. voice/male 与 voice/female 的 id 集合一致(storyline/rpg 的平铺 NPC/角色句除外)
3. 无 0 字节文件;ogg/m4a 均可被 ffprobe 解码且时长 > 0
输出 Markdown 报告到 stdout(重定向进 docs/audio/final-asset-check.md)。
"""
import os
import subprocess
import sys
ROOT = "/home/ubuntu/SoundGame/web"
GAMES = ["storyline", "escape", "simon", "rhythm", "space", "rpg"]
# 平铺 voice 文件(角色/NPC,单音色,不做性别集合一致性要求)
FLAT_OK = {"storyline", "rpg"}
def ids(d, ext):
if not os.path.isdir(d):
return set()
return {f[: -len(ext)] for f in os.listdir(d) if f.endswith(ext)}
def main():
problems = []
print("# 全项目音频资产终检(R5,2026-08-29)\n")
print("| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 |")
print("|---|---|---|---|---|---|")
for g in GAMES:
base = os.path.join(ROOT, g, "audio")
oggs, m4as, zero, bad = [], [], [], []
for dirpath, _dirs, files in os.walk(base):
for f in files:
p = os.path.join(dirpath, f)
if os.path.getsize(p) == 0:
zero.append(p)
if f.endswith(".ogg"):
oggs.append(p)
elif f.endswith(".m4a"):
m4as.append(p)
# 双格式配对
o_set = {p[:-4] for p in oggs}
m_set = {p[:-4] for p in m4as}
for p in sorted(o_set - m_set):
problems.append(f"{g}: 缺 m4a —— {p}.ogg")
for p in sorted(m_set - o_set):
problems.append(f"{g}: 缺 ogg —— {p}.m4a")
# 解码校验(抽查全量 ffprobe)
for p in oggs + m4as:
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", p], capture_output=True, text=True)
try:
if float(r.stdout.strip()) <= 0:
bad.append(p)
except ValueError:
bad.append(p)
for p in zero:
problems.append(f"{g}: 0 字节 —— {p}")
for p in bad:
problems.append(f"{g}: 解码失败/时长为 0 —— {p}")
# voice 性别集合一致性
vm = ids(os.path.join(base, "voice", "male"), ".ogg")
vf = ids(os.path.join(base, "voice", "female"), ".ogg")
if vm == vf and vm:
pair = f"✅ 各 {len(vm)}"
else:
pair = f"❌ male={len(vm)} female={len(vf)}"
for i in sorted(vm - vf):
problems.append(f"{g}: voice 缺 female/{i}")
for i in sorted(vf - vm):
problems.append(f"{g}: voice 缺 male/{i}")
flat = ids(os.path.join(base, "voice"), ".ogg")
flat_note = f"(平铺 {len(flat)} 个角色/NPC 句,豁免)" if g in FLAT_OK and flat else \
(f"(⚠️ 非豁免游戏存在平铺 {len(flat)} 个)" if flat and g not in FLAT_OK else "")
print(f"| {g} | {len(oggs)} | {len(m4as)} | {len(zero)} | {len(bad)} | {pair}{flat_note} |")
print()
if problems:
print(f"## 问题清单({len(problems)} 条)\n")
for p in problems:
print(f"- {p}")
return 1
print("## 结论\n\n全部通过:双格式配对完整、无 0 字节、全部可解码、voice 性别目录 id 集合一致(storyline/rpg 平铺角色句按约定豁免)。")
return 0
if __name__ == "__main__":
sys.exit(main())
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# R4 RPG《剑与声》SA3 音效批量生成(29 件,SYN 的 5 件不在此列)。
# 用法: bash tools/gen_rpg_sfx.sh (内部 flock 串行 GPU;输出 tools/gen_rpg_sfx_wav/)
set -u
source /home/ubuntu/SoundGame/env.d/sfx.sh
cd /home/ubuntu/SoundGame/tools/stable-audio-3
OUT=/home/ubuntu/SoundGame/tools/gen_rpg_sfx_wav
mkdir -p "$OUT"
declare -A P D
# ---- 敌方前摇(1.5s 生成,后裁 1.0s 格挡窗口) ----
P[tel_wolf]="wolf growl then lunge, short, dry, close mic"; D[tel_wolf]=1.5
P[tel_bandit]="footsteps on dirt then sword unsheathing shing, short, dry"; D[tel_bandit]=1.5
P[tel_spider]="insect legs skittering, high frequency chitin clicks, eerie, short"; D[tel_spider]=1.5
P[tel_elite]="heavy armor plates clanking, menacing, short"; D[tel_elite]=1.5
P[tel_boss1]="two metal blades striking each other twice, dramatic ring, short"; D[tel_boss1]=1.5
P[tel_croc]="water splash with low reptile growl, short"; D[tel_croc]=1.5
P[tel_shaman]="low guttural chant humming, ritualistic, dark, short"; D[tel_shaman]=1.5
P[tel_puppet]="wooden mechanism clicks and winding gears, short"; D[tel_puppet]=1.5
P[tel_boss2]="low human voice humming a dark ritual melody, ominous, short"; D[tel_boss2]=1.5
# ---- 战斗 ----
P[atk_swing]="sword whoosh through air, fast, clean, single swing"; D[atk_swing]=0.8
P[atk_hit]="sword hitting flesh, dull impact, not gory, single hit"; D[atk_hit]=0.8
P[parry]="metal parry clang, bright ring, short, single hit"; D[parry]=0.8
P[skill_slash]="powerful sword slash with air rip, dramatic, single strike"; D[skill_slash]=1.0
P[enemy_die]="body falling to ground with weapon clatter, short"; D[enemy_die]=1.2
P[potion]="drinking liquid gulps from glass bottle, short"; D[potion]=1.5
# ---- 探索/系统 ----
P[chest]="old wooden chest creaking open, short"; D[chest]=1.5
P[drink_water]="scooping and drinking stream water, gentle, short"; D[drink_water]=1.5
P[rest]="settling down by campfire, fabric rustle, calm, short"; D[rest]=2.0
P[door_heavy]="heavy wooden gate slowly opening, deep creak"; D[door_heavy]=3.0
# ---- 环境(6s 循环素材) ----
P[amb_village]="night village ambience, campfire crackle, distant chatter, crickets, loopable"; D[amb_village]=6
P[amb_path]="forest path ambience, wind through leaves, distant birds, loopable"; D[amb_path]=6
P[amb_fork]="forest crossroads ambience, gusty wind, crows cawing, loopable"; D[amb_fork]=6
P[amb_chest]="hollow tree hole ambience, water drips, muffled wind, loopable"; D[amb_chest]=6
P[amb_stream]="flowing stream over rocks, gentle water babbling, loopable"; D[amb_stream]=6
P[amb_hunter]="wood cabin ambience, firewood crackle, occasional knife sharpening, loopable"; D[amb_hunter]=6
P[amb_deepwood]="deep forest ambience at night, urgent insects, distant wolf howl, ominous, loopable"; D[amb_deepwood]=6
P[amb_gate]="bandit camp outside wooden gate ambience, torches burning, rowdy distant voices, loopable"; D[amb_gate]=6
P[amb_hall]="large empty hall ambience, fire brazier crackle, echoing space, tense, loopable"; D[amb_hall]=6
P[amb_swamp]="swamp ambience, bubbling mud, frogs, insects, murky, loopable"; D[amb_swamp]=6
P[amb_peak]="mountain peak ambience, strong cold wind, thin air, howling gusts, loopable"; D[amb_peak]=6
ORDER="tel_wolf tel_bandit tel_spider tel_elite tel_boss1 tel_croc tel_shaman tel_puppet tel_boss2 atk_swing atk_hit parry skill_slash enemy_die potion chest drink_water rest door_heavy amb_village amb_path amb_fork amb_chest amb_stream amb_hunter amb_deepwood amb_gate amb_hall amb_swamp amb_peak"
flock /home/ubuntu/SoundGame/.gpu.lock bash -c '
'"$(declare -p P D OUT)"'
for k in '"$ORDER"'; do
if [ -s "$OUT/$k.wav" ]; then echo "skip $k"; continue; fi
uv run stable-audio --model small-sfx -p "${P[$k]}" --duration "${D[$k]}" -o "$OUT/$k.wav" 2>&1 | grep -i "saved\|error"
done'
echo "SA3 batch done: $(ls "$OUT" | wc -l) files"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""RPG《剑与声》程序合成音效(5 件):hurt flee step coin levelup。
输出 /tmp/rpg_syn/*.wav(44.1kHz 立体声,峰值归一 0.89),随后 ffmpeg 转 ogg+m4a。
音高敏感件用合成保证可控:levelup 为 A 小调五声(A4 C5 D5 E5 A5)上行钟声琶音。
"""
import os
import numpy as np
import soundfile as sf
sr = 44100
OUT = "/tmp/rpg_syn"
os.makedirs(OUT, exist_ok=True)
def norm(y, peak=0.89):
return (y / np.max(np.abs(y)) * peak).astype(np.float32)
def stereo(y):
return np.stack([y, y], axis=1)
def place(y, start_s, sig):
s = int(start_s * sr)
n = min(len(sig), len(y) - s)
y[s:s + n] += sig[:n]
# hurt:低频闷击(60Hz 轰 + 平滑噪声团),与 atk_hit 的金属/肉质听感区分
t = np.arange(int(sr * 0.35)) / sr
thud = np.sin(2 * np.pi * 60 * t) * np.exp(-t * 18)
noise = np.random.default_rng(7).standard_normal(len(t))
noise = np.convolve(noise, np.ones(64) / 64, mode="same") * np.exp(-t * 25) * 0.6
sf.write(f"{OUT}/hurt.wav", stereo(norm(thud + noise)), sr)
# flee:渐远脚步(4 步,幅度逐次衰减、越走越闷)
y = np.zeros(int(sr * 1.2), dtype=np.float32)
for i in range(4):
L = int(0.12 * sr)
tt = np.arange(L) / sr
w = 32 + i * 24
st = np.convolve(np.random.default_rng(i).standard_normal(L), np.ones(w) / w, mode="same")
st *= np.exp(-tt * 30) * (0.9 ** i)
place(y, i * 0.28, st)
sf.write(f"{OUT}/flee.wav", stereo(norm(y)), sr)
# step:单步脚步(短噪声团 + 90Hz 触点)
t = np.arange(int(sr * 0.22)) / sr
n = np.random.default_rng(3).standard_normal(len(t))
body = np.convolve(n, np.ones(24) / 24, mode="same") * np.exp(-t * 35)
toe = np.sin(2 * np.pi * 90 * t) * np.exp(-t * 30) * 0.5
sf.write(f"{OUT}/step.wav", stereo(norm(body + toe)), sr)
# coin:硬币叮当(非谐波高频泛音簇,两次碰撞)
y = np.zeros(int(sr * 0.45), dtype=np.float32)
for start, dec in [(0.0, 28), (0.12, 32)]:
seg = np.arange(int(sr * (0.45 - start))) / sr
sig = sum(a * np.sin(2 * np.pi * f * seg) * np.exp(-seg * dec) * 0.3
for f, a in [(5200, 1.0), (6900, 0.7), (9100, 0.45), (11400, 0.3)])
place(y, start, sig)
sf.write(f"{OUT}/coin.wav", stereo(norm(y)), sr)
# levelup:上行钟声琶音(A 小调五声 A4 C5 D5 E5 A5;钟 = 基音 + 非谐波泛音快衰减)
y = np.zeros(int(sr * 1.4), dtype=np.float32)
for i, f in enumerate([440.0, 523.25, 587.33, 659.25, 880.0]):
seg = np.arange(int(sr * 1.0)) / sr
bell = sum(a * np.sin(2 * np.pi * f * r * seg) * np.exp(-seg * d)
for r, a, d in [(1, 1.0, 4), (2.4, 0.35, 7), (3.9, 0.15, 10)])
place(y, i * 0.18, bell * 0.4)
sf.write(f"{OUT}/levelup.wav", stereo(norm(y)), sr)
print("SYN done:", sorted(os.listdir(OUT)))
+781
View File
@@ -0,0 +1,781 @@
# -*- coding: utf-8 -*-
"""双性别语音资产管线(R1):为 5 个 web 游戏生成 male/female 两版旁白 voice。
约定:
- 台词表从各游戏已有 gen 脚本原样提取(storyline/escape 的原始表在 app/src/<flavor>/gen/,
本脚本只做只读引用,不改 app/)。
- storyline 只生成旁白 + 系统音(NARR* 角色)的双性别版本;角色台词(林晚/猎狗/小满等)
不在本脚本范围内,沿用 web/storyline/audio/voice/ 平铺原文件。
- 输出 staging wav: tools/gen_voice_dual_wav/<game>/<gender>/<id>.wav
--convert 后落地: web/<game>/audio/voice/<gender>/<id>.{ogg,m4a}
(原有平铺 voice/*.ogg 保留不动,等引擎切换后由后续轮次清理。)
质量控制:
- 每条 assert 时长 > 0.3s、峰值 > 0.05;统一峰值归一 0.89。
- 台词 > 40 字按句拆分生成再拼接(句间 0.12s 静音),避免长句吞字。
- 短句/数字类(文本 ≤ 2 字)同 instruct 一整批连续生成,减少 prosody 漂移。
- --check 对比同句两性时长,相差 3 倍以上判异常(删掉异常 wav 后重跑本脚本即可)。
用法(GPU 必须串行):
source env.d/tts.sh
flock /home/ubuntu/SoundGame/.gpu.lock .venv-tts/bin/python tools/gen_voice_dual.py --game simon --gender both
.venv-tts/bin/python tools/gen_voice_dual.py --game all --check # 无需 GPU
source env.d/android.sh && .venv-tts/bin/python tools/gen_voice_dual.py --game all --convert
"""
import argparse
import os
import re
import subprocess
import sys
os.environ.setdefault("HF_HOME", "/home/ubuntu/SoundGame/models/hf")
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
ROOT = "/home/ubuntu/SoundGame"
STAGING = os.path.join(ROOT, "tools", "gen_voice_dual_wav")
SPLIT_LIMIT = 40 # 超过 40 字拆句
GAP_SEC = 0.12 # 拼接句间静音
PEAK_NORM = 0.89
BATCH_SHORT = 16 # 数字/短句一批
BATCH_LONG = 6
# ---------------------------------------------------------------- 音色分配
# 游戏 × 性别 -> speaker。说明见 docs/audio/asset-manifest.md。
# Ryan(英语母语音色)经冒烟测试未采用:项目已有中文发音投诉,男声统一用
# 中文母语的 Uncle_Fu;测试样本在 tools/samples/ryan_zh_*.wav 供人工试听。
SPEAKERS = {
"storyline": {"male": "Uncle_Fu", "female": "Vivian"},
"escape": {"male": "Uncle_Fu", "female": "Serena"},
"simon": {"male": "Uncle_Fu", "female": "Serena"},
"rhythm": {"male": "Uncle_Fu", "female": "Vivian"},
"space": {"male": "Uncle_Fu", "female": "Serena"},
"rpg": {"male": "Uncle_Fu", "female": "Serena"},
}
# instruct 对:(male, female)。None 表示两性别共用同一 instruct。
def _pair(m, f=None):
return (m, f if f is not None else m)
# ---------------------------------------------------------------- storyline
_SL_NARR = _pair("低沉醇厚的中年男声旁白,冷静、缓慢,带着末世废墟的苍凉感",
"冷静低沉的女声旁白,语速缓慢,带着末世废墟的苍凉感")
_SL_WARM = _pair("低沉醇厚的中年男声旁白,由紧张转为温暖,结尾带希望",
"女声旁白,由紧张转为温暖,结尾带希望")
_SL_COLD = _pair("低沉醇厚的中年男声旁白,冷漠、空洞,越说越轻",
"女声旁白,冷漠、空洞,越说越轻")
_SL_EPIC = _pair("低沉醇厚的中年男声旁白,悲壮,尾音下沉",
"女声旁白,悲壮,尾音下沉")
STORYLINE_LINES = [
# ---- 系统语音 ----
("help", _SL_NARR, "操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。"),
("status1", _SL_NARR, "当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。"),
("status2", _SL_NARR, "当前进度:第二幕,酸雨笼罩的街区与断桥。你的目标:渡过江水,抵达广播塔。小心,猎狗的人,就在附近。"),
("status3", _SL_NARR, "当前进度:第三幕,广播塔下。你的目标:把警报发出去,找到林晨。这是最后一段路了。"),
("menu", _SL_NARR, "菜单。向左滑:重新开始整个故事。向右滑,或者单击:回到故事。向上滑:听操作说明。"),
# ---- 选择提示 ----
("p1", _SL_NARR, "请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。"),
("p2", _SL_NARR, "请选择。向左滑:钻进旁边的通风管道。向右滑:躺进路边的尸体堆里装死。"),
("p3", _SL_NARR, "请选择。向左滑:循着哭声过去看看。向右滑:不去管它,直接去北区。"),
("p4", _SL_NARR, "请选择。向左滑:带上小满一起走。向右滑:留下食物,独自上路。"),
("p5", _SL_NARR, "请选择。向左滑:走桥下的污水涵洞。向右滑:爬桥上悬着的钢索。"),
("p6", _SL_NARR, "请选择。向左滑:交出坐标,保命要紧。向右滑:拒绝他,想办法周旋。"),
("p7", _SL_NARR, "请选择。向左滑:追上去,警告避难所。向右滑:自己逃命,越远越好。"),
("p8", _SL_NARR, "请选择。向左滑:用公开频道广播,让全城都听见。向右滑:用加密频段,只发给避难所。"),
# ---- 第一幕 ----
("n00_intro", _SL_NARR, "大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:单击屏幕,继续剧情;双击,重听上一句;向上滑,听你的状态和帮助;向下滑,呼出菜单。当我说“请选择”的时候,向左滑,或者向右滑,做出你的决定。选择一旦做出,就无法回头。现在,单击屏幕,睁开眼睛。"),
("n01_wake", _SL_NARR, "后脑一阵钝痛,嘴里全是铁锈味。你在废弃的地铁站里醒来,四周黑得像沉在水底。背包还压在身下,手电筒却不知去向。远处滴水的声音,一下,一下,像某种倒计时。"),
("n04_exit", _SL_NARR, "你扶着潮湿的墙,一步一步往外摸。第五十步,脚尖踢到一个硬物——是一支手电,还剩半格电。光柱劈开黑暗的那一瞬,你看见墙上用红漆刷着一行大字:别去北区。"),
("n05_signal", _SL_NARR, "就在这时,背包侧袋里的收音机,沙沙声忽然变得清晰。一个平稳的女声,一遍又一遍地重复着:"),
("n06_whistle", _SL_NARR, "你刚走上街面,黑暗里忽然响起一声口哨。接着是第二声,第三声,从不同的方向,把你围在中间。有人的手电光,扫过你的脸。"),
("n08_vent", _SL_NARR, "你一矮身,钻进了通风管道。铁皮贴着脊背,窄得几乎无法呼吸。身后传来咒骂和铁棍敲击管道口的声音,越来越近,又渐渐远去。十分钟后,你从两条街外的井口爬出来,肺里像塞满了沙子。"),
("n09_dead", _SL_NARR, "你屏住呼吸,把自己埋进路边冰冷的尸体堆里。手电、罐头、半瓶水,被他们一样一样摸走。有人踢了你一脚,你纹丝不动。就在他们离开时,你听见一句不该听见的话——“北区那个陷阱,今晚就收网。”"),
# ---- 第二幕 ----
("n10_rain", _SL_NARR, "酸雨开始下了。街面上升起细密的嘶嘶声,像无数条小蛇在游。雨幕那头,隐约传来一阵哭声——细细的,是个孩子。"),
("n11_child", _SL_NARR, "哭声来自一间塌了一半的便利店。一个小男孩抱着膝盖坐在货架底下,身边躺着一个再也不会醒来的女人。他抬起头,脸上全是灰,小声说:“我叫小满。妈妈说,数到一千,她就会醒。我已经数到……数到好多个一千了。”"),
("n13_leave", _SL_NARR, "你把仅剩的一罐罐头放在他身边,转身走进雨里。身后,孩子重新开始数数:一……二……三……你告诉自己,别回头。千万别回头。"),
("n14_ignore", _SL_NARR, "哭声被你甩在身后,越来越远,最后混进雨声里,再也听不见了。你走得很快,快得像在逃跑。快一点,再快一点。你对自己说,只要到了北区,这一切就都值了。"),
("n15_bridge", _SL_NARR, "两个小时后,江水拦住了去路。跨江大桥断成两截,桥身斜插进黑沉沉的江面。要去广播塔,只剩两条路:桥下的污水涵洞,黑得看不见底;或者,桥上悬着的钢索,在风里荡来荡去。"),
("n16_culvert", _SL_NARR, "涵洞里,污水没到胸口,又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西,擦着你的腿滑了过去。你没有叫出声,只是走,一步,一步,直到看见洞口那点灰白的天光。"),
("n16b_culvert_c", _SL_NARR, "你让小满骑在肩上,自己蹚进没到胸口的污水里。又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西擦过你的腿,小满吓得一抖,你抬手按住他的膝盖:“数数。”他小声数起来,一,二,三……数到一百七,你们看见了洞口的天光。"),
("n17_cable", _SL_NARR, "钢索比想象中更滑。酸雨把它泡得发黏,你每挪一步,整根钢索就晃一下。江风从断桥的缺口灌进来,像有无数只手在水底下拽你的脚。你不往下看。你只知道,前面就是北岸。"),
("n17b_cable_c", _SL_NARR, "钢索比想象中更滑。小满趴在你背上,一声不吭,两条小胳膊勒得你生疼。走到一半,钢索猛地一沉,你听见身后钢丝崩断的脆响。“闭眼。”你说。然后你用尽全身力气,向前,向前,直到膝盖磕上北岸的石头。"),
# ---- 第三幕 ----
("n20_convoy", _SL_NARR, "猎狗的车队朝北区开走了,探照灯在雨幕里划出一道道口子。你蹲在原地,胃里一阵一阵地泛酸。晨晨在北区。而刚才,是你,亲手把狼,引到了她的门口。"),
("n22_fight", _SL_NARR, "你往后退了半步,忽然笑了:“坐标在我脑子里。杀了我,你什么都拿不到。”猎狗愣了半秒。就是这半秒,你抓起一把沙土扬在他脸上,转身就跑。子弹擦着肩膀打进墙里,火辣辣地疼。但你在跑,你在跑——活着的感觉,从来没有这么清楚过。"),
("n23_tower", _SL_NARR, "广播塔就立在头顶,红色的信号灯在雨里一闪一闪,像一颗还在跳动的心脏。塔底的中控台居然还能用。只要爬上去,你就能接入城市广播网,把警报发给北区的每一个人。问题是——用哪个频道。"),
# ---- 结局 ----
("e1_dawn", _SL_WARM, "加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。单击屏幕,可以回到故事开始的地方。"),
("e2_price", _SL_EPIC, "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。单击屏幕,重新开始。"),
("e3_alone", _SL_COLD, "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。单击屏幕,重新开始。"),
("e4_static", _SL_EPIC, "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。单击屏幕,重新开始。"),
]
# ---------------------------------------------------------------- escape
# R2 重设计(docs/design/01):全游戏同一 instruct 常量(00 文档 §3.2,不含语速词)。
_ESC = _pair("低沉平稳的男声,冷静、克制,带一点神秘感",
"沉稳平静的女声,冷静、克制,带一点神秘感")
ESCAPE_LINES = [
# ---- 开场引导 G0-G7 ----
("g0_intro", _ESC, "黑暗。你在废弃的地下设施里醒来,什么都想不起来。你必须找到出路。"),
("g1_move", _ESC, "先活动一下。朝任意方向滑一下,走一小步。"),
("g1_done", _ESC, "很好,你迈出了第一步。"),
("g2_wall", _ESC, "这个房间不大,四面都是墙。再滑两下,试试碰碰墙。"),
("g3_left", _ESC, "跟着我指的路走。向左滑。"),
("g4_down", _ESC, "对。再向下滑。"),
("g5_inspect", _ESC, "听到水声了吗?单击一下,仔细检查这个房间。"),
("g5_key", _ESC, "你摸到了一把钥匙!"),
("g6_doubletap", _ESC, "钥匙能开锁。双击,是把物品用出去的用法。现在,双击试试。"),
("g6_done", _ESC, "对,就是这样。接下来,靠你自己了。"),
("g7_goal", _ESC, "找到能用这把钥匙打开的东西。需要帮助时,在原地双击。"),
# ---- 引导纠错与跳过 ----
("guide_wrong_up", _ESC, "不是这个方向。向上滑。"),
("guide_wrong_down", _ESC, "不是这个方向。向下滑。"),
("guide_wrong_left", _ESC, "不是这个方向。向左滑。"),
("guide_wrong_right", _ESC, "不是这个方向。向右滑。"),
("guide_wrong_gesture", _ESC, "不是这个操作。照我说的做。"),
("guide_blunt_up", _ESC, "手指贴着屏幕,向上推一下。"),
("guide_blunt_down", _ESC, "手指贴着屏幕,向下推一下。"),
("guide_blunt_left", _ESC, "手指贴着屏幕,向左推一下。"),
("guide_blunt_right", _ESC, "手指贴着屏幕,向右推一下。"),
("guide_offpath", _ESC, "先跟我走。"),
("guide_skip", _ESC, "跳过引导,直接开始自由探索。"),
("reopen_prompt", _ESC, "上滑,重新听一遍引导。单击,直接开始。"),
# ---- 卡关三级提示 + 导航 ----
("stuck_ask", _ESC, "需要帮助吗?"),
("stuck_key_1", _ESC, "铁锈味最重的地方,值得仔细看看。"),
("stuck_key_2", _ESC, "去西南角的储物间,单击检查,找到钥匙。"),
("stuck_key_3", _ESC, "跟我说的方向走,去西南角的储物间。"),
("stuck_cabinet_1", _ESC, "钥匙就在你身上。它能打开某一把锁。"),
("stuck_cabinet_2", _ESC, "去东边的铁柜房,双击,用钥匙打开铁柜。"),
("stuck_cabinet_3", _ESC, "跟我说的方向走,去铁柜所在的房间。"),
("stuck_box_1", _ESC, "保险丝属于电箱。有电流声的地方,就有电箱。"),
("stuck_box_2", _ESC, "去西北角的电箱房,双击,把保险丝装进电箱。"),
("stuck_box_3", _ESC, "跟我说的方向走,去电箱所在的房间。"),
("stuck_gate_1", _ESC, "电已经通了。该去找那扇铁门了。"),
("stuck_gate_2", _ESC, "去东南角的大门,双击,打开它,离开这里。"),
("stuck_gate_3", _ESC, "跟我说的方向走,去大门所在的房间。"),
("nav_up", _ESC, "向上滑。"),
("nav_down", _ESC, "向下滑。"),
("nav_left", _ESC, "向左滑。"),
("nav_right", _ESC, "向右滑。"),
# ---- 房间描述 ----
("room_storage", _ESC, "一间储物间。货架上堆满杂物,角落里传来水滴声。"),
("room_cabinet", _ESC, "这个房间里,立着一个铁柜。"),
("room_power", _ESC, "这个房间的墙上,有一只电箱。"),
("room_gate", _ESC, "面前,是一扇沉重的铁门。"),
# ---- 用时播报 4 段整句 ----
("time_band1", _ESC, "你逃出来了,用时不到两分钟。了不起!"),
("time_band2", _ESC, "你逃出来了,用时不到五分钟。"),
("time_band3", _ESC, "你逃出来了,用时不到十分钟。"),
("time_band4", _ESC, "你逃出来了,用时超过十分钟。"),
# ---- 保留句(文本沿用,按新 instruct 重录) ----
("wall", _ESC, "那边是墙,换个方向。"),
("start_room", _ESC, "你站在设施中央的房间里。滑动移动,单击检查,双击使用物品。"),
("found_key", _ESC, "你蹲下身在储物架的角落里摸索,指尖碰到一个冰凉的金属物件——是一把钥匙!钥匙到手了。"),
("inspect_cabinet_locked", _ESC, "这个房间里立着一个铁柜,柜门上挂着一把锁。也许,有什么钥匙能打开它。"),
("unlock_cabinet", _ESC, "钥匙插进锁孔,缓缓转动。咔哒一声,柜门开了。柜子深处,躺着一枚保险丝。保险丝到手了。"),
("inspect_box_dead", _ESC, "墙上是一只电箱,箱盖敞开着,里面的保险丝座空空如也。没有电,这里的大门永远打不开。"),
("box_fixed", _ESC, "电箱运转正常,电流的嗡鸣声平稳而持续。"),
("fix_box", _ESC, "你把保险丝用力按进卡座。火花一闪,嗡鸣声响起——头顶的线路恢复供电了!"),
("inspect_gate", _ESC, "面前是一扇沉重的铁门。门边的电子锁一片漆黑,没有任何反应。它需要电力。"),
("inspect_gate_ready", _ESC, "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。双击,打开它,离开这里。"),
("escape", _ESC, "你扳动开关。电机轰鸣,沉重的铁门缓缓升起,刺眼的光线涌了进来。你眯起眼睛,向前迈出了第一步。自由了。你逃出来了。"),
("use_fail", _ESC, "这里没有用得上东西的地方。换个房间试试,或者先单击检查一下周围。"),
("inv_empty", _ESC, "你摸了摸身上,什么也没有。"),
("inv_key", _ESC, "你身上带着一把钥匙。"),
("inv_fuse", _ESC, "你身上带着一枚保险丝。"),
("inv_both", _ESC, "你身上带着一把钥匙和一枚保险丝。"),
("replay", _ESC, "黑暗再次合拢。你回到了中央的房间。这一次,你会更快。"),
("again", _ESC, "单击屏幕,再来一次。下滑,打开菜单。"),
("empty_1", _ESC, "房间里空空荡荡,只有你自己的回声。"),
("empty_2", _ESC, "你伸出手,摸到的只有冰冷粗糙的墙壁。"),
("empty_3", _ESC, "除了远处的水滴声,这里什么都没有。"),
# ---- 菜单与帮助 ----
("help_ops", _ESC, "滑动,在房间之间移动。单击,检查房间,或者重听房间的声音。双击,使用物品,或者查看背包。"),
("menu_open", _ESC, "菜单。"),
("menu_restart", _ESC, "重新开始。"),
("menu_home", _ESC, "回大厅。"),
("menu_back", _ESC, "返回游戏。"),
]
# ---------------------------------------------------------------- simon
# R2 重设计(docs/design/02):全游戏同一 instruct 常量,轮次/成绩全部整句化。
_SIMON = _pair("平静、清晰,像一个耐心的游戏主持人")
_SIMON_ROUNDS = ["第一", "第二", "第三", "第四", "第五", "第六", "第七", "第八", "第九", "第十",
"第十一", "第十二", "第十三", "第十四", "第十五", "第十六", "第十七", "第十八", "第十九", "第二十"]
SIMON_LINES = [
# ---- 交互式教学 T0-T7 ----
("t0_welcome", _SIMON, "欢迎来到音序回响。先认识六种声音。"),
("t0_goal", _SIMON, "我会演奏一串声音,请你记住顺序,原样复现。"),
("t1_up", _SIMON, "向上滑,是这个声音。"),
("t2_down", _SIMON, "向下滑,是这个声音。"),
("t3_left", _SIMON, "向左滑,是这个声音。"),
("t4_right", _SIMON, "向右滑,是这个声音。"),
("t5_tap", _SIMON, "单击,是这个声音。"),
("t6_double", _SIMON, "双击,是这个声音。"),
("t_try", _SIMON, "你试试。"),
("t_ok", _SIMON, "对了。"),
("t_wrong_up", _SIMON, "不是这个,向上滑试试。"),
("t_wrong_down", _SIMON, "不是这个,向下滑试试。"),
("t_wrong_left", _SIMON, "不是这个,向左滑试试。"),
("t_wrong_right", _SIMON, "不是这个,向右滑试试。"),
("t_wrong_tap", _SIMON, "不是这个,单击试试。"),
("t_wrong_double", _SIMON, "不是这个,双击试试。"),
("t_blunt_up", _SIMON, "手指贴着屏幕,向上推一下。"),
("t_blunt_down", _SIMON, "手指贴着屏幕,向下推一下。"),
("t_blunt_left", _SIMON, "手指贴着屏幕,向左推一下。"),
("t_blunt_right", _SIMON, "手指贴着屏幕,向右推一下。"),
("t_blunt_tap", _SIMON, "用指尖,轻轻点一下屏幕。"),
("t_blunt_double", _SIMON, "用指尖,快速点两下屏幕。"),
("t7_ready", _SIMON, "六种声音都记住了。后面的序列,会越来越长。"),
("t7_retry", _SIMON, "答错可以消耗机会重听。双击,可以随时重听。"),
("t_skip", _SIMON, "跳过教学,直接开始。"),
# ---- 轮次播报(整句,round_01..round_20 + 兜底) ----
*[(f"round_{i + 1:02d}", _SIMON, f"{w}轮,听好了。") for i, w in enumerate(_SIMON_ROUNDS)],
("round_more", _SIMON, "新的一轮,听好了。"),
# ---- 成绩播报 6 段整句 ----
("score_band1", _SIMON, "一轮都没坚持下来。别灰心。"),
("score_band2", _SIMON, "你坚持了三轮以内。"),
("score_band3", _SIMON, "你坚持了五轮左右。"),
("score_band4", _SIMON, "你坚持了将近十轮。"),
("score_band5", _SIMON, "你坚持了十轮以上,很厉害。"),
("score_band6", _SIMON, "你坚持了十五轮以上,大师级!"),
# ---- 流程句 ----
("your_turn", _SIMON, "轮到你了。"),
("chances_out", _SIMON, "重听机会用完了,小心。"),
("retry_use", _SIMON, "错了。用一次机会,再听一遍。"),
("relisten", _SIMON, "再听一遍。"),
("replay", _SIMON, "单击屏幕,再来一局。"),
("rules_prompt", _SIMON, "上滑,重听规则。下滑,打开菜单。"),
("rules", _SIMON, "上行音,向上滑。下行音,向下滑。左边的音,向左滑。右边的音,向右滑。一声轻响,单击。两声轻响,双击。"),
("gameover", _SIMON, "游戏结束。"),
# ---- 反馈短句 ----
("correct", _SIMON, "答对了!"),
("wrong", _SIMON, "错了。"),
("chance_plus", _SIMON, "机会加一。"),
("new_record", _SIMON, "新纪录!"),
# ---- 菜单 ----
("menu_open", _SIMON, "菜单。"),
("menu_restart", _SIMON, "重新开始。"),
("menu_home", _SIMON, "回大厅。"),
("menu_back", _SIMON, "返回游戏。"),
]
# ---------------------------------------------------------------- rhythm
# R3 重构(docs/design/03-rhythm-voice-ids.md):31 句,全表同一 instruct 常量。
_RHY = _pair("低沉醇厚的男声,像节奏游戏主持人,平稳有力",
"活力清晰的年轻女声,像节奏游戏主持人,情绪饱满但不夸张")
RHYTHM_LINES = [
# ---- W 组 · 开场与流程 ----
("welcome", _RHY, "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。"),
("welcome_back", _RHY, "欢迎回来。我奏一句,你跟一句。"),
("tut_1", _RHY, "听,底鼓。在下一小节的同一个位置,单击。"),
("tut_2", _RHY, "这次是左和右。跟着打,左滑,右滑。"),
("tut_3", _RHY, "最后一句,三个音,中间是双击。"),
("tut_done", _RHY, "很好,演出开始。"),
("tut_skip", _RHY, "跳过教学,直接开始。"),
("mid_song", _RHY, "过半了,保持。"),
# ---- C 组 · 教学纠正 ----
("retry_early", _RHY, "早了,等鼓点再出手。"),
("retry_late", _RHY, "晚了,跟着鼓点走。"),
("retry_gesture", _RHY, "不是这个手势,再听一遍。"),
("retry_listen", _RHY, "再听一遍,跟着来。"),
("retry_skip", _RHY, "没关系,我们先走下一句。"),
# ---- S 组 · 选曲与解锁 ----
("select_hint", _RHY, "左右滑选曲目,单击开始,下滑回大厅。"),
("song1_name", _RHY, "第一首,启程,九十拍。"),
("song2_name", _RHY, "第二首,追风,一百零五拍。"),
("song3_name", _RHY, "第三首,星爆,一百二十拍。"),
("song_locked", _RHY, "这首还没解锁。上一首拿到 B 级以上就能解锁。"),
("unlock_song2", _RHY, "新曲目解锁,追风!"),
("unlock_song3", _RHY, "新曲目解锁,星爆!"),
# ---- G 组 · 结算 ----
("grade_s", _RHY, "S 级!准确率超过九成五,完美演出。"),
("grade_a", _RHY, "A 级!准确率超过八成五,非常出色。"),
("grade_b", _RHY, "B 级,准确率超过七成。不错,还能更好。"),
("grade_c", _RHY, "C 级,准确率不到七成。别灰心,再来一遍。"),
("new_best_combo", _RHY, "新连击纪录!"),
("restart", _RHY, "单击再来一遍,右滑下一首,下滑回大厅。"),
# ---- M 组 · 菜单与规则 ----
("rules", _RHY, "规则:我奏一句,你在下一小节原样跟打。音要对,拍点也要对。"),
("settings_hint", _RHY, "音色、语速、背景音乐,请回大厅修改。"),
("menu_main", _RHY, "菜单。单击切换选项,双击确认。"),
("menu_resume", _RHY, "继续演奏。"),
("menu_lobby", _RHY, "回大厅。"),
]
# ---------------------------------------------------------------- space
# R3 备产(docs/design/04-space-voice-ids.md):52 句全量替换 R1 版,全表同一 instruct 常量。
_SPC = _pair("低沉醇厚的男声,像飞船领航员,沉稳有力",
"平静清晰的女声,像飞船领航员,沉稳可信")
SPACE_LINES = [
# ---- D 组 · 交互式演练 ----
("drill_welcome", _SPC, "欢迎登上盲航。你只有一双耳朵。"),
("drill_rule", _SPC, "陨石来了,会有声音告诉你方位。"),
("drill_left", _SPC, "听,左边有陨石。向左滑,把它拨开!"),
("drill_right", _SPC, "这次在右边。向右滑!"),
("drill_up", _SPC, "上面有东西,声音又尖又高。向上滑!"),
("drill_down", _SPC, "下面是低沉的轰隆。向下滑!"),
("drill_good", _SPC, "漂亮。"),
("drill_retry_left", _SPC, "那是左边来的,向左滑。"),
("drill_retry_right", _SPC, "那是右边来的,向右滑。"),
("drill_retry_up", _SPC, "声音又高又尖,是上面,向上滑。"),
("drill_retry_down", _SPC, "轰隆声在下面,向下滑。"),
("drill_moveon", _SPC, "没关系,跟着我,我们继续。"),
("drill_done", _SPC, "四个方向你都会了。引擎点火,出发!"),
("drill_skip", _SPC, "跳过演练,直接出发。"),
# ---- P 组 · 保护期 ----
("protect_left", _SPC, "训练护盾接住了。那颗在左边,听到左边的呼啸,向左滑。"),
("protect_right", _SPC, "训练护盾接住了。那颗在右边,听到右边的呼啸,向右滑。"),
("protect_up", _SPC, "训练护盾接住了。那颗在上面,听到高高的呼啸,向上滑。"),
("protect_down", _SPC, "训练护盾接住了。那颗在下面,听到低沉的轰隆,向下滑。"),
("protect_end", _SPC, "训练护盾关闭。接下来,靠你自己了。"),
# ---- F 组 · 单击/双击分批引入 ----
("feat_boost", _SPC, "试试单击,引擎会短促加速,直接冲过空隙。"),
("feat_boost_done", _SPC, "感觉到了吗?加速的时候,陨石撞不上你。"),
("feat_shield", _SPC, "双击展开护盾,能挡下一次撞击。"),
("feat_shield_done", _SPC, "护盾展开后会自动充能,好了我会告诉你。"),
("shield_ready", _SPC, "护盾充能完毕。"),
("shield_block", _SPC, "护盾挡下了撞击。"),
# ---- H 组 · 被撞归因 ----
("hit_left", _SPC, "左边撞上的。听到左边的呼啸,向左滑。"),
("hit_right", _SPC, "右边撞上的。听到右边的呼啸,向右滑。"),
("hit_up", _SPC, "上面撞上的。听到高高的呼啸,向上滑。"),
("hit_down", _SPC, "下面撞上的。听到低沉的轰隆,向下滑。"),
("hit_last", _SPC, "船体只剩最后一次了,小心!"),
# ---- M 组 · 里程碑 ----
("ms_10", _SPC, "十颗了,稳。"),
("ms_25", _SPC, "二十五颗,老练的飞行员。"),
("ms_50", _SPC, "五十颗,传奇。"),
("ms_100", _SPC, "一百颗!天空属于你。"),
# ---- R 组 · 结算 ----
("time_1", _SPC, "不到三十秒,再试一次。"),
("time_2", _SPC, "超过三十秒,有进步。"),
("time_3", _SPC, "超过一分钟,合格的飞行员。"),
("time_4", _SPC, "超过两分钟,王牌飞行员。"),
("dodge_1", _SPC, "只拨开几颗。"),
("dodge_2", _SPC, "拨开了十来颗。"),
("dodge_3", _SPC, "拨开了几十颗。"),
("dodge_4", _SPC, "拨开了一大群,漂亮。"),
("new_record", _SPC, "新纪录!"),
("restart_hint", _SPC, "单击再飞一次,下滑回大厅。"),
# ---- Y 组 · 菜单与系统 ----
("second_run_ask", _SPC, "上滑重新听演练,单击直接出发。"),
("menu_main", _SPC, "菜单。单击切换选项,双击确认。"),
("menu_resume", _SPC, "继续飞行。"),
("menu_lobby", _SPC, "回大厅。"),
("menu_help", _SPC, "重新听教学要点。"),
("help_recap", _SPC, "左边来向左滑,右边来向右滑,上面来向上滑,下面来向下滑。单击加速,双击护盾。"),
("settings_hint", _SPC, "音色、语速、背景音乐,请回大厅修改。"),
("paused", _SPC, "已暂停。"),
]
# ---------------------------------------------------------------- rpg
# R4 新游戏《剑与声》第一章(docs/design/05-rpg-ch1-voice-ids.md + 05-rpg-addendum.md §6.2)。
# 旁白全表同一 instruct 常量;NPC 单音色平铺见 RPG_NPC。
_RPG = _pair("低沉醇厚的男声,像说书人,平静有韵味",
"平静清晰的年轻女声,像在讲武侠故事")
RPG_LINES = [
# ---- A 组 · 序章 ----
("intro_1", _RPG, "你的眼睛看不见了。但你的耳朵,比谁都灵。"),
("intro_2", _RPG, "黑风寨掳走了你唯一的徒弟。追进迷雾林,把他们连根拔起。"),
("intro_3", _RPG, "先记住,怎么用耳朵走路。"),
# ---- S 组 · 系统/菜单/状态 ----
("sys_continue_ask", _RPG, "检测到你的旅程记录。单击继续,右滑开始新的旅程。"),
("sys_new_game", _RPG, "新的旅程,开始了。"),
("sys_saved", _RPG, "进度已保存。"),
("sys_rest", _RPG, "你在篝火边睡了一觉,状态全满。"),
("sys_drink", _RPG, "溪水清冽,你喝了个饱,伤势尽消。"),
("sys_menu", _RPG, "菜单。左右滑选择,单击确认。"),
("sys_menu_resume", _RPG, "继续旅程。"),
("sys_menu_save", _RPG, "保存并回大厅。"),
("sys_menu_help", _RPG, "重新听帮助。"),
("help_explore", _RPG, "左右滑切换选项,单击确认。双击重新听场景,上滑查看状态,下滑打开菜单。"),
("help_battle", _RPG, "战斗中,左右滑选招式,单击出手。听到敌人的前摇,双击格挡。"),
("settings_hint", _RPG, "音色、语速、背景音乐,请回大厅修改。"),
("st_hp_high", _RPG, "你状态很好。"),
("st_hp_mid", _RPG, "你受了伤。"),
("st_hp_low", _RPG, "你伤势很重,命悬一线。"),
("st_gold_poor", _RPG, "口袋空空。"),
("st_gold_some", _RPG, "有些积蓄。"),
("st_gold_rich", _RPG, "腰包鼓鼓。"),
("st_lv_low", _RPG, "你初出茅庐。"),
("st_lv_mid", _RPG, "你小有名气。"),
("st_lv_high", _RPG, "你已是高手。"),
("st_weapon_none", _RPG, "你赤手空拳。"),
("st_weapon_iron", _RPG, "你手持铁剑。"),
("st_weapon_steel", _RPG, "你手持青钢剑。"),
("st_weapon_wind", _RPG, "你手持听风剑。"),
("st_armor_none", _RPG, "你身无片甲。"),
("st_armor_leather", _RPG, "你身着皮甲。"),
("st_armor_iron", _RPG, "你身着铁鳞甲。"),
# ---- D 组 · 场景描述 ----
("desc_village", _RPG, "雾口村。篝火噼啪作响,村民低声议论着林子里的土匪。"),
("desc_village_re", _RPG, "你回到了雾口村。"),
("desc_path", _RPG, "林间小路。风穿过树叶,脚下的落叶沙沙作响。"),
("desc_path_re", _RPG, "你回到林间小路。"),
("desc_fork", _RPG, "岔路口。风声在左右两边,听起来不太一样。"),
("desc_fork_re", _RPG, "你回到岔路口。"),
("desc_chest", _RPG, "一个枯树洞。洞里似乎藏着什么。"),
("desc_chest_re", _RPG, "你回到枯树洞前。"),
("desc_stream", _RPG, "溪流。水声潺潺,是个歇脚的好地方。"),
("desc_stream_re", _RPG, "你回到溪流边。"),
("desc_hunter", _RPG, "猎人小屋。柴火正旺,还有磨刀的声音。"),
("desc_hunter_re", _RPG, "你回到猎人小屋。"),
("desc_deepwood", _RPG, "密林深处。虫鸣急促,远处有狼嚎,杀气很重。"),
("desc_deepwood_re", _RPG, "你回到密林深处。"),
("desc_gate", _RPG, "黑风寨门前。火把熊熊,人声嘈杂。"),
("desc_hall", _RPG, "聚义厅。火盆熊熊,大厅空旷,回声冰冷。"),
# ---- O 组 · 选项名 ----
("opt_go_path", _RPG, "前进,去林间小路。"),
("opt_go_fork", _RPG, "前进,去岔路口。"),
("opt_go_chest", _RPG, "往左,看看树洞。"),
("opt_go_stream", _RPG, "往右,去溪流。"),
("opt_go_hunter", _RPG, "前进,去猎人小屋。"),
("opt_go_deepwood", _RPG, "前进,进密林深处。"),
("opt_go_gate", _RPG, "前进,去寨门。"),
("opt_back", _RPG, "返回。"),
("opt_shop", _RPG, "去商店。"),
("opt_rest", _RPG, "休息一晚。"),
("opt_status", _RPG, "查看状态。"),
("opt_investigate", _RPG, "调查四周。"),
("opt_open_chest", _RPG, "打开树洞。"),
("opt_drink", _RPG, "饮水休息。"),
("opt_talk_hunter", _RPG, "和老猎人谈谈。"),
("opt_assault", _RPG, "强攻寨门。"),
("opt_sneak", _RPG, "绕行潜入。"),
("opt_leave", _RPG, "离开。"),
# ---- E 组 · 事件 ----
("evt_gold15", _RPG, "你在落叶下摸到十五枚金币。"),
("evt_nothing", _RPG, "这里什么都没有了。"),
("evt_chest", _RPG, "树洞里有两瓶金疮药,你收好了。"),
("evt_hunter_gift", _RPG, "老猎人塞给你一瓶金疮药,还说商店会给你算便宜些。"),
("evt_gate_open", _RPG, "寨门缓缓打开。"),
("evt_ch1_end", _RPG, "寨主倒了,但徒弟不在这里。一张字条,指向南方的腐沼。"),
# ---- B 组 · 战斗 ----
("btl_start_wolf", _RPG, "野狼拦路!"),
("btl_start_bandit", _RPG, "山贼围上来了!"),
("btl_start_spider", _RPG, "毒蛛从树上垂了下来!"),
("btl_start_elite", _RPG, "寨丁头目挡住了去路!"),
("btl_start_boss1", _RPG, "黑风寨主,就在你面前。"),
("cmd_attack", _RPG, "攻击。"),
("cmd_skill", _RPG, "破空斩。"),
("cmd_defend", _RPG, "防御。"),
("cmd_item", _RPG, "金疮药。"),
("btl_nomp", _RPG, "内劲不足。"),
("btl_noitem", _RPG, "没有金疮药了。"),
("btl_flee", _RPG, "你抽身退出了战斗。"),
("btl_noflee", _RPG, "避无可避!"),
("btl_half_bandit", _RPG, "山贼的攻势缓了下来。"),
("btl_enemy_die", _RPG, "敌人倒下了。"),
("btl_win", _RPG, "战斗胜利。"),
("btl_loot_small", _RPG, "小有收获。"),
("btl_loot_mid", _RPG, "收获不少。"),
("btl_loot_big", _RPG, "大获全胜,收获颇丰。"),
("btl_levelup", _RPG, "你的修为精进了。"),
("btl_lose", _RPG, "你倒下了,黑暗吞没了一切。"),
("btl_lose_hint", _RPG, "记住,听清前摇,双击格挡。"),
("tut_b1", _RPG, "它要扑了,听前摇!"),
("tut_b2", _RPG, "就是现在,双击格挡!"),
("tut_b3", _RPG, "挡住了!轮到你了。左右滑选攻击,单击出剑。"),
("tut_b4", _RPG, "没挡住也没关系,再来一次。"),
("tut_b5", _RPG, "好剑法!接下来,靠你自己了。"),
# ---- SH 组 · 商店 ----
("shop_open", _RPG, "商店。左右滑看看货,单击购买。"),
("shop_price_sword", _RPG, "铁剑,一百金。"),
("shop_price_armor", _RPG, "皮甲,八十金。"),
("shop_price_potion", _RPG, "金疮药,三十金。"),
("shop_owned", _RPG, "这件你已经有了。"),
("shop_poor", _RPG, "钱不够。"),
("shop_buy", _RPG, "买下了。"),
# ---- addendum §6.2 补录:敌方血量档位(旁白) ----
("foe_hp_high", _RPG, "敌人气焰正盛。"),
("foe_hp_mid", _RPG, "敌人露出疲态。"),
("foe_hp_low", _RPG, "敌人撑不住了。"),
]
# NPC 单音色平铺(id, speaker, instruct, 台词)→ web/rpg/audio/voice/<id>.*
# 角色 speaker+instruct 恒定(村长与寨主同 speaker 不同 instruct,从不在同场景出现)。
RPG_NPC = [
("npc_headman_1", "Uncle_Fu", "慈祥和缓的老者", "外乡人,林子里不太平。这五十金,拿着防身。"),
("npc_headman_2", "Uncle_Fu", "慈祥和缓的老者", "商店就在村里,缺什么就去看看。"),
("npc_headman_3", "Uncle_Fu", "慈祥和缓的老者", "听说你是用耳朵走路的剑客?保重啊。"),
("npc_headman_4", "Uncle_Fu", "慈祥和缓的老者", "救救那些被掳走的孩子吧。"),
("npc_hunter_1", "Eric", "沙哑粗犷,豪爽中带着暖意", "站住。……哦,是个用耳朵的。"),
("npc_hunter_2", "Eric", "沙哑粗犷,豪爽中带着暖意", "寨子里有我旧识。带上这个,别死在路上。"),
("npc_hunter_3", "Eric", "沙哑粗犷,豪爽中带着暖意", "黑风寨主的双刀很快,听清他出刀前的动静。"),
("npc_hunter_4", "Eric", "沙哑粗犷,豪爽中带着暖意", "去吧。让我听听,你的传说。"),
("npc_elite_1", "Dylan", "凶狠粗鲁,大声喝骂", "哪儿来的瞎子,敢闯黑风寨!"),
("npc_elite_half", "Dylan", "凶狠粗鲁,大声喝骂", "你……你到底是什么人!"),
("npc_elite_die", "Dylan", "凶狠粗鲁,大声喝骂", "寨主……会替我报仇……"),
("npc_boss1_1", "Uncle_Fu", "低沉阴狠,慢条斯理", "盲剑客?正好,省得挖你的眼睛。"),
("npc_boss1_2", "Uncle_Fu", "低沉阴狠,慢条斯理", "有两下子。可惜,你听不懂死期的声音。"),
("npc_boss1_die", "Uncle_Fu", "低沉阴狠,慢条斯理", "白无瞳……会找到你……"),
]
NPCS = {"rpg": RPG_NPC}
GAMES = {
"storyline": STORYLINE_LINES,
"escape": ESCAPE_LINES,
"simon": SIMON_LINES,
"rhythm": RHYTHM_LINES,
"space": SPACE_LINES,
"rpg": RPG_LINES,
}
# ---------------------------------------------------------------- 工具函数
def split_text(text, limit=SPLIT_LIMIT):
"""按句末标点拆句并合并到 <=limit 字;超长单句再按逗号拆。"""
if len(text) <= limit:
return [text]
sents = [s for s in re.split(r"(?<=[。!?;…])", text) if s]
chunks, cur = [], ""
for s in sents:
if cur and len(cur) + len(s) > limit:
chunks.append(cur)
cur = s
else:
cur += s
if cur:
chunks.append(cur)
out = []
for c in chunks:
if len(c) <= limit:
out.append(c)
else: # 单句仍超限,按逗号硬拆
parts = [p for p in re.split(r"(?<=[,、:])", c) if p]
buf = ""
for p in parts:
if buf and len(buf) + len(p) > limit:
out.append(buf)
buf = p
else:
buf += p
if buf:
out.append(buf)
return [o for o in out if o.strip()]
def staging_path(game, gender, fid):
return os.path.join(STAGING, game, gender, fid + ".wav")
def valid_wav(path):
import numpy as np
import soundfile as sf
if not os.path.exists(path):
return False
try:
data, sr = sf.read(path)
except Exception:
return False
if len(data) == 0:
return False
dur = len(data) / sr
peak = float(np.max(np.abs(data)))
return dur > 0.3 and peak > 0.05
# ---------------------------------------------------------------- 生成
def generate(games, genders, only):
import numpy as np
import soundfile as sf
import torch
from qwen_tts import Qwen3TTSModel
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
device_map="cuda:0",
dtype=torch.bfloat16,
)
print("model loaded", flush=True)
for game in games:
lines = GAMES[game]
if only:
lines = [x for x in lines if x[0] in only]
for gender in genders:
speaker = SPEAKERS[game][gender]
ins_idx = 0 if gender == "male" else 1
outdir = os.path.join(STAGING, game, gender)
os.makedirs(outdir, exist_ok=True)
# 展开成 (fid, chunk_idx, n_chunks, text, instruct),跳过已完成
items = []
for fid, ins_pair, text in lines:
path = staging_path(game, gender, fid)
if valid_wav(path):
continue
chunks = split_text(text)
for ci, ctext in enumerate(chunks):
items.append((fid, ci, len(chunks), ctext, ins_pair[ins_idx]))
if not items:
print(f"[{game}/{gender}] nothing to do", flush=True)
continue
# 短句(数字类)优先、同 instruct 聚合成整批,减少 prosody 漂移
shorts = [it for it in items if len(it[3]) <= 2]
longs = [it for it in items if len(it[3]) > 2]
queue = ([("short", shorts[i:i + BATCH_SHORT]) for i in range(0, len(shorts), BATCH_SHORT)]
+ [("long", longs[i:i + BATCH_LONG]) for i in range(0, len(longs), BATCH_LONG)])
results = {} # fid -> {chunk_idx: wav}
for bi, (kind, batch) in enumerate(queue):
wavs, sr = model.generate_custom_voice(
text=[b[3] for b in batch],
language=["Chinese"] * len(batch),
speaker=[speaker] * len(batch),
instruct=[b[4] for b in batch],
max_new_tokens=2048,
)
for (fid, ci, _n, _t, _i), wav in zip(batch, wavs):
results.setdefault(fid, {})[ci] = np.asarray(wav, dtype=np.float32)
print(f"[{game}/{gender}] batch {bi + 1}/{len(queue)} ({kind}, n={len(batch)})", flush=True)
gap = np.zeros(int(GAP_SEC * sr), dtype=np.float32)
for fid, ins_pair, text in lines:
path = staging_path(game, gender, fid)
if valid_wav(path):
continue
got = results.get(fid, {})
n_chunks = len(split_text(text))
assert len(got) == n_chunks, f"{game}/{gender}/{fid}: 缺 chunk {len(got)}/{n_chunks}"
wav = got[0]
for ci in range(1, n_chunks):
wav = np.concatenate([wav, gap, got[ci]])
peak = float(np.max(np.abs(wav)))
assert peak > 0.05, f"{game}/{gender}/{fid}: peak {peak:.3f}"
wav = wav / peak * PEAK_NORM
dur = len(wav) / sr
assert dur > 0.3, f"{game}/{gender}/{fid}: dur {dur:.2f}s"
sf.write(path, wav, sr)
print(f"[ok] {game}/{gender}/{fid} {dur:.2f}s chunks={n_chunks}", flush=True)
# NPC 单音色平铺(staging <game>/npc/,convert 落 flat voice/)
npc_lines = NPCS.get(game)
if npc_lines:
outdir = os.path.join(STAGING, game, "npc")
os.makedirs(outdir, exist_ok=True)
todo = [x for x in npc_lines if not valid_wav(os.path.join(outdir, x[0] + ".wav"))]
for i in range(0, len(todo), BATCH_LONG):
batch = todo[i:i + BATCH_LONG]
wavs, sr = model.generate_custom_voice(
text=[b[3] for b in batch],
language=["Chinese"] * len(batch),
speaker=[b[1] for b in batch],
instruct=[b[2] for b in batch],
max_new_tokens=2048,
)
for (fid, _sp, _ins, _t), wav in zip(batch, wavs):
wav = np.asarray(wav, dtype=np.float32)
peak = float(np.max(np.abs(wav)))
assert peak > 0.05, f"{game}/npc/{fid}: peak {peak:.3f}"
wav = wav / peak * PEAK_NORM
dur = len(wav) / sr
assert dur > 0.3, f"{game}/npc/{fid}: dur {dur:.2f}s"
sf.write(os.path.join(outdir, fid + ".wav"), wav, sr)
print(f"[ok] {game}/npc/{fid} {dur:.2f}s", flush=True)
# ---------------------------------------------------------------- 检查
def check(games):
import numpy as np
import soundfile as sf
problems = []
for game in games:
print(f"=== {game} ===")
for fid, _ins, _text in GAMES[game]:
durs = {}
for gender in ("male", "female"):
path = staging_path(game, gender, fid)
if not os.path.exists(path):
durs[gender] = None
continue
data, sr = sf.read(path)
peak = float(np.max(np.abs(data)))
durs[gender] = len(data) / sr
if durs[gender] <= 0.3 or peak <= 0.05:
problems.append(f"{game}/{gender}/{fid}: 质量不达标 dur={durs[gender]:.2f} peak={peak:.3f}")
m, f = durs["male"], durs["female"]
if m is None or f is None:
problems.append(f"{game}/{fid}: 缺 {'male' if m is None else 'female'}")
continue
ratio = max(m, f) / min(m, f)
flag = " <-- OUTLIER" if ratio > 3 else ""
if ratio > 3:
problems.append(f"{game}/{fid}: 时长离群 m={m:.2f}s f={f:.2f}s ratio={ratio:.1f}")
print(f" {fid:24s} m={m:6.2f}s f={f:6.2f}s{flag}")
print()
if problems:
print("PROBLEMS:")
for p in problems:
print(" ", p)
return 1
print("ALL OK")
return 0
# ---------------------------------------------------------------- 转码
def convert(games):
# 只按台词表转换(不扫目录),staging 里残留的旧 id wav 不会落盘
total = 0
for game in games:
for gender in ("male", "female"):
dst_dir = os.path.join(ROOT, "web", game, "audio", "voice", gender)
os.makedirs(dst_dir, exist_ok=True)
for fid, _ins, _text in GAMES[game]:
src = staging_path(game, gender, fid)
if not os.path.exists(src):
print(f"[convert] WARN missing {game}/{gender}/{fid}.wav")
continue
base = os.path.join(dst_dir, fid)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
"-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
"-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True)
total += 1
# NPC 平铺(staging <game>/npc/ → audio/voice/<id>.*)
for fid, _sp, _ins, _t in NPCS.get(game, []):
src = os.path.join(STAGING, game, "npc", fid + ".wav")
if not os.path.exists(src):
print(f"[convert] WARN missing {game}/npc/{fid}.wav")
continue
base = os.path.join(ROOT, "web", game, "audio", "voice", fid)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
"-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
"-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True)
total += 1
print(f"[convert] {game} done")
print(f"converted {total} files (ogg+m4a each)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--game", required=True,
choices=list(GAMES.keys()) + ["all"])
ap.add_argument("--gender", default="both", choices=["male", "female", "both"])
ap.add_argument("--only", default="", help="逗号分隔的 id 列表,只生成这些")
ap.add_argument("--check", action="store_true")
ap.add_argument("--convert", action="store_true")
args = ap.parse_args()
games = list(GAMES.keys()) if args.game == "all" else [args.game]
genders = ["male", "female"] if args.gender == "both" else [args.gender]
only = set(x for x in args.only.split(",") if x)
if args.check:
sys.exit(check(games))
if args.convert:
convert(games)
return
generate(games, genders, only)
if __name__ == "__main__":
main()
+63
View File
@@ -29,3 +29,66 @@ ffmpeg:裁剪前 120s → 首尾 3s acrossfade(tri)无缝循环化 → lou
I=-20/TP=-2/LRA=11 → aresample 44.1kHz 立体声 → libvorbis `-q:a 3`。
成品均 117.00s(storyline mean -22.3dB,escape mean -21.1dB),保持原文件名
直接替换 assets,游戏代码零改动。原合成版备份在 `tools/bgm_backup/`。
| 3 | 157922006990851 | rhythm《节拍行者》groove.ogg | constant tempo 120 bpm electronic dance music, steady four-on-the-floor kick drum beat, driving synth bass, crisp hi-hats, energetic, no tempo changes, seamless loop, instrumental | succeeded | $0.03 |
- 任务 3(rhythm):候选 0 = 158.35s(choice 157922107719682);候选 1 = 194.23s(choice 157922107719683)。flac 存 `tools/mureka_out/rhythm/`。
| 3 | 157922351054851 | space《盲航》engine.ogg | deep spaceship engine hum ambient, continuous low drone with subtle pulsing thruster texture, calm tension, dark sci-fi atmosphere, seamless loop, instrumental | succeeded | $0.03 |
space:选用候选 0(180.03s,choice 157922441101313),后处理同前(120s 裁剪+3s 首尾 crossfade+loudnorm -20LUFS),成品 117.00s mean -21.5dB。space 预算线:$0.03/$0.15。
simon:任务 157922504015873,一次成功(首次 429 重试 1 次),候选 0 = 202.7s 选用(choice 157922638299138),裁剪 117s 循环化 + loudnorm -20。估 $0.03。
## 2026-08-29 R3:rhythm《节拍行者》重构 BGM(track90/105/120)
预算线:本轮 $0.30;总预算 $2.00(5 轮)。
| # | task id | 用途 | 状态 | 花费 |
|---|---|---|---|---|
| 6 | 157963509563393 | track90 首提(2 候选) | succeeded | $0.03 |
| 7 | 157963652431875 | track105 首提(2 候选) | succeeded | $0.03 |
| 8 | 157963818106881 | track120 首提(2 候选) | succeeded | $0.03 |
| 9 | 157964073893889 | track90 重提(第 2 次,2 候选) | succeeded | $0.03 |
| 10 | 157964212305921 | track120 重提(第 2 次,2 候选) | succeeded | $0.03 |
**本轮花费:$0.15 / $0.30。累计:$0.30 / $2.00。**
### BPM 实测与选用(librosa 梳状扫描法 tools/bpm_precise.py,合成 105 点击轨校准=105.00 精确)
注意:`librosa.beat.beat_track` 在 22050/512 下按整帧量化(105 附近一档=4.3 BPM),
读数 103.36/107.67 均为量化伪影,不作为判据;以梳状扫描(0.05 BPM 步进+抛物线插值)为准。
- track90:首提 121.0 / 98.75,重提 80.0 / 120.0 —— 4 候选全部不达标。已达"每首最多重试 1 次"上限,
选用首提候选 1(98.75,choice 157963627134980)以 atempo=0.911392 拉伸至 90,
成品实测 **90.01 BPM**(循环 85.33s = 32 小节)。
- track105:首提候选 0 实测 **105.00 精确**(choice 157963780292611,168.03s),无需重提/拉伸;
成品实测 **104.99 BPM**(循环 73.14s = 32 小节)。
- track120:首提 123.05 / 117.45(beat_track 量化读数)不达标,重提候选 0 实测 **120.00 精确**
(choice 157964317097985,208.59s);成品实测 **120.00 BPM**(循环 72.00s = 36 小节)。
### 后处理
强拍起点裁剪(首个强 onset 对齐小节头)→ 整数小节裁剪 + 末 2 拍与开头 acrossfade(tri)
无缝循环 → loudnorm I=-20/TP=-2/LRA=11 → 44.1kHz → ogg q:a 3 + m4a aac 128k。
成品:track90=85.33s / track105=73.14s / track120=72.00s,mean -21.2/-21.3/-19.9dB。
旧 `bgm/groove`(R2 的 120bpm 单曲)已删除。flac 原件存 `tools/mureka_out/rhythm_track*/`。
## 2026-08-29 R4:rpg《剑与声》BGM(village/battle/boss/ending,4 首)
预算线:本轮 $0.30;总预算 $2.00(5 轮)。addendum 裁定探索期无音乐(amb 走 sfx 层),bgm/explore 取消。
| # | task id | 用途 | 状态 | 花费 |
|---|---|---|---|---|
| 11 | 157968630808577 | rpg village(2 候选) | succeeded | $0.03 |
| 12 | 157968792551426 | rpg battle(2 候选) | succeeded | $0.03 |
| 13 | 157968932864001 | rpg boss(2 候选) | succeeded | $0.03 |
| 14 | 157969071407105 | rpg ending(2 候选) | succeeded | $0.03 |
**本轮花费:$0.12 / $0.30。累计:$0.42 / $2.00。**
选用:village=候选 1(choice 157968750477315,229.2s);battle=候选 0(choice 157968893214722,212.8s);
boss=候选 1(choice 157969029332994,178.2s);ending=候选 1(choice 157969188913155,248.4s)。
一次成功无重试。RPG 无节拍网格玩法,BPM 不做硬校验(prompt 已带 70/100/120 bpm 特征词)。
后处理:首个强 onset 起点 → 90s 裁剪 + 首尾 3s acrossfade(tri) 无缝循环 → loudnorm I=-20/TP=-2/LRA=11
→ 44.1kHz → ogg q:a 3 + m4a 128k。成品均 90.00s(mean -23.0/-20.5/-22.6/-21.9dB)。
flac 原件存 tools/mureka_out/rpg_*/。
+32
View File
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""Ryan 音色中文发音冒烟测试:决定 simon/rhythm 男声旁白选型。"""
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
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
device_map="cuda:0",
dtype=torch.bfloat16,
)
texts = [
"欢迎来到音序回响。我会用六种声音演奏一段序列,请你记住顺序。",
"听好了:上行音,向上滑;下行音,向下滑。预备,开始!",
"零一二三四五六七八九十",
]
wavs, sr = model.generate_custom_voice(
text=texts,
language=["Chinese"] * len(texts),
speaker=["Ryan"] * len(texts),
instruct=["热情活力的中文游戏解说语气,吐字清晰"] * len(texts),
)
os.makedirs("/home/ubuntu/SoundGame/tools/samples", exist_ok=True)
for i, wav in enumerate(wavs):
wav = np.asarray(wav, dtype=np.float32)
sf.write(f"/home/ubuntu/SoundGame/tools/samples/ryan_zh_{i}.wav", wav, sr)
print(f"ryan_zh_{i}: {len(wav)/sr:.2f}s peak={np.max(np.abs(wav)):.3f}")
print("done")