feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决 - 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏 - 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言 - 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步 - 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿 - 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描) - 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目 验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全项目音频资产终检(R5)。
|
||||
|
||||
检查 8 个游戏 web/<game>/audio/:
|
||||
检查 11 个游戏 web/<game>/audio/(批次 3 起含 hunt;hunt 的 bot 喊话落
|
||||
audio/sfx/npc_*,不在 voice 平铺豁免范围,23-hunt.md §15 裁决 7):
|
||||
1. 每个 .ogg 都有同名 .m4a(反之亦然)
|
||||
2. voice/male 与 voice/female 的 id 集合一致(storyline/rpg 的平铺 NPC/角色句除外)
|
||||
3. 键盘变体目录 voice/<gender>/key/(P1 路线 a):
|
||||
@@ -17,7 +18,7 @@ import sys
|
||||
|
||||
ROOT = "/home/xsl/SoundGame/web"
|
||||
GAMES = ["storyline", "escape", "simon", "rhythm", "space", "rpg", "radio", "dungeon",
|
||||
"abyss", "detective"]
|
||||
"abyss", "detective", "hunt"]
|
||||
# 平铺 voice 文件(角色/NPC,单音色,不做性别集合一致性要求)
|
||||
FLAT_OK = {"storyline", "rpg", "radio", "abyss", "detective"}
|
||||
|
||||
@@ -30,7 +31,7 @@ def ids(d, ext):
|
||||
|
||||
def main():
|
||||
problems = []
|
||||
print("# 全项目音频资产终检(R5 建立,批次 1 起含 radio/dungeon,批次 2 起含 abyss/detective,2026-09-09)\n")
|
||||
print("# 全项目音频资产终检(R5 建立,批次 1 起含 radio/dungeon,批次 2 起含 abyss/detective,批次 3 起含 hunt,2026-09-13)\n")
|
||||
print("| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 | key male/female 一致且 ⊆ 主集 |")
|
||||
print("|---|---|---|---|---|---|---|")
|
||||
for g in GAMES:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# 《围猎》BGM 后处理(批次 3):Mureka flac 候选 -> 90s 无缝循环 ogg+m4a。
|
||||
# 用法: bash tools/gen_hunt_bgm.sh (flac 原件在 tools/mureka_out/,不入库)
|
||||
#
|
||||
# 工艺(web/detective/gen/gen_bgm.sh 同款,tools/mureka_log.md 先例):
|
||||
# 首个强 onset 起点(silenceremove,-35dB 门限)→ 93s 裁剪 → 首尾 3s acrossfade(tri)
|
||||
# 无缝循环 → loudnorm I=-20/TP=-2/LRA=11(23-hunt.md §10.1,两首均 -20 LUFS;
|
||||
# 结算态 0.5 volume 由游戏层控制)→ aresample 44.1kHz
|
||||
# → ogg libvorbis q3 + m4a aac 128k,落 web/hunt/audio/bgm/。
|
||||
# hunt 无节拍网格玩法,100bpm 仅为 prompt 特征词,不跑 bpm_precise(dungeon 先例)。
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
export PATH="$PWD/tools/bin:$PATH"
|
||||
|
||||
TMP=tools/bgm_post_wav/hunt # 中间 wav(不入库,可由本脚本复现)
|
||||
DST=web/hunt/audio/bgm
|
||||
mkdir -p "$TMP" "$DST"
|
||||
|
||||
post() { # post <flac> <track> <LUFS>
|
||||
local src="$1" track="$2" lufs="$3"
|
||||
local tmp="$TMP/$track.wav"
|
||||
ffmpeg -y -v error -i "$src" -af \
|
||||
"silenceremove=start_periods=1:start_threshold=-35dB:start_silence=0.02,atrim=end=93,asetpts=PTS-STARTPTS,asplit[a][b];[b]atrim=start=90,asetpts=PTS-STARTPTS[t];[a][t]acrossfade=d=3:c1=tri:c2=tri,loudnorm=I=${lufs}:TP=-2:LRA=11,aresample=44100" \
|
||||
"$tmp"
|
||||
ffmpeg -y -v error -i "$tmp" -c:a libvorbis -q:a 3 "$DST/$track.ogg"
|
||||
ffmpeg -y -v error -i "$tmp" -c:a aac -b:a 128k "$DST/$track.m4a"
|
||||
echo "$track <- $src (I=${lufs})"
|
||||
}
|
||||
|
||||
# 选用候选 = 更接近 90s 的那支(拿不准原则),详见 tools/mureka_log.md 批次 3 节
|
||||
post tools/mureka_out/hunt-menu/160549591842821_0.flac menu -20 # 177.3s
|
||||
post tools/mureka_out/hunt-hunt/160549425905665_0.flac hunt -20 # 192.6s
|
||||
|
||||
echo "== hunt bgm done: $(ls "$DST" | wc -l) files =="
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《围猎》程序合成音效(23-hunt.md §10.2,13 件,SYN 全部程序合成兜底)。
|
||||
|
||||
落盘:staging wav → tools/gen_hunt_syn_wav/<id>.wav(44.1kHz 立体声);
|
||||
成品 → web/hunt/audio/sfx/<id>.{ogg,m4a}(tools/bin/ffmpeg 双格式)。
|
||||
pan 由运行时引擎 opts.pan / 游戏层 panner 承担,资产本体为双声道同信号(gen_rpg_syn 先例)。
|
||||
|
||||
one-shot QC 标准(11 件):去直流 + 首尾 8ms cos² 淡化 + 编码后解码回读
|
||||
真峰值 ≤ -3dBFS(MDCT 过冲坑:以 ebur128 peak=true 实测为准,超限自动降档重编)。
|
||||
loop QC 标准(2 件,4s 无缝循环):只去直流(首尾禁淡化,事件网格周期化 + 尾部
|
||||
回卷 overlap-add 保证循环点连续),另做真峰值校验只报告不强制。
|
||||
|
||||
频带分离(§8 R8):loop 低频 100-300Hz / howl 中高 1-3kHz / 打击音宽带动感。
|
||||
时长口径(§10.2/R5):非语音信息全部 <1s——号角 0.9s、哨音 0.6s、chime 0.4s、
|
||||
击杀 0.5-0.7s;loop 源 4.0s。
|
||||
|
||||
用法:.venv-tts/bin/python tools/gen_hunt_syn.py
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg")
|
||||
OUT = os.path.join(ROOT, "tools", "gen_hunt_syn_wav")
|
||||
DST = os.path.join(ROOT, "web", "hunt", "audio", "sfx")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
os.makedirs(DST, exist_ok=True)
|
||||
|
||||
sr = 44100
|
||||
ONE_SHOT_TP_CEIL = -3.0 # dBFS,one-shot 真峰值上限
|
||||
SAMPLE_PEAK = 0.66 # ≈ -3.6dBFS 采样峰值,给 MDCT 过冲留余量
|
||||
FADE_N = int(sr * 0.008) # 8ms cos²
|
||||
|
||||
_rng = np.random.default_rng(23)
|
||||
|
||||
|
||||
def dc(y):
|
||||
return y - np.mean(y)
|
||||
|
||||
|
||||
def fade(y):
|
||||
w = np.sin(np.linspace(0, np.pi / 2, FADE_N)) ** 2 # cos² 等价形态
|
||||
y = y.copy()
|
||||
y[:FADE_N] *= w
|
||||
y[-FADE_N:] *= w[::-1]
|
||||
return y
|
||||
|
||||
|
||||
def norm(y, peak=SAMPLE_PEAK):
|
||||
m = np.max(np.abs(y))
|
||||
return (y / m * peak) if m > 0 else y
|
||||
|
||||
|
||||
def stereo(y):
|
||||
return np.stack([y, y], axis=1).astype(np.float32)
|
||||
|
||||
|
||||
def place(y, start_s, sig):
|
||||
s = int(start_s * sr)
|
||||
n = min(len(sig), len(y) - s)
|
||||
y[s:s + n] += sig[:n]
|
||||
|
||||
|
||||
def bp_noise(n, lo, hi, order=4):
|
||||
"""简易零相位带通噪声(FFT 矩形窗,合成音效用足够)。"""
|
||||
x = _rng.standard_normal(n)
|
||||
X = np.fft.rfft(x)
|
||||
freqs = np.fft.rfftfreq(n, 1 / sr)
|
||||
mask = (freqs >= lo) & (freqs <= hi)
|
||||
# 软边界,降噼啪
|
||||
soft = np.clip(mask.astype(float), 0, 1)
|
||||
y = np.fft.irfft(X * soft, n)
|
||||
return y / (np.max(np.abs(y)) + 1e-9)
|
||||
|
||||
|
||||
def sine_track(n, f0, f1, amp=1.0, vib_hz=0.0, vib_depth=0.0):
|
||||
"""线性滑音正弦(可选颤音),相位连续。"""
|
||||
t = np.arange(n) / sr
|
||||
f = np.linspace(f0, f1, n)
|
||||
if vib_hz:
|
||||
f = f * (1 + vib_depth * np.sin(2 * np.pi * vib_hz * t))
|
||||
ph = 2 * np.pi * np.cumsum(f) / sr
|
||||
return amp * np.sin(ph)
|
||||
|
||||
|
||||
def harm_stack(n, f0, f1, harms, vib_hz=0.0, vib_depth=0.0):
|
||||
"""谐波簇滑音:harms=[(ratio, gain), ...]。"""
|
||||
y = np.zeros(n)
|
||||
for r, g in harms:
|
||||
y += g * sine_track(n, f0 * r, f1 * r, 1.0, vib_hz, vib_depth)
|
||||
return y
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- one-shot 渲染
|
||||
def r_atk_whoosh(): # 挥击风声 0.3s:下扫带通噪声,快起快落
|
||||
n = int(sr * 0.30)
|
||||
t = np.arange(n) / sr
|
||||
y = bp_noise(n, 400, 2600)
|
||||
sweep = np.exp(-((t - 0.10) / 0.11) ** 2) # 10ms 处峰顶的粗包络
|
||||
env = (1 - np.exp(-t * 90)) * np.exp(-t * 14)
|
||||
return dc(y * env * (0.6 + 0.4 * sweep))
|
||||
|
||||
|
||||
def r_atk_swish(): # 挥空空荡风声 0.3s:更高更空、无低频着落(归因真话:没打到)
|
||||
n = int(sr * 0.30)
|
||||
t = np.arange(n) / sr
|
||||
y = bp_noise(n, 900, 3400)
|
||||
env = (1 - np.exp(-t * 70)) * np.exp(-t * 11)
|
||||
hollow = 1 + 0.35 * np.sin(2 * np.pi * 7 * t) # 轻微空洞感 AM
|
||||
return dc(y * env * hollow * 0.8)
|
||||
|
||||
|
||||
def r_kill_wolf(): # 狼哀嚎倒地 0.5s:短促下滑哀嚎 + 着地闷响
|
||||
n = int(sr * 0.50)
|
||||
t = np.arange(n) / sr
|
||||
yelp = harm_stack(n, 1150, 520, [(1, 1.0), (2, 0.35), (3, 0.12)], vib_hz=9, vib_depth=0.03)
|
||||
yelp *= (1 - np.exp(-t * 120)) * np.exp(-t * 9)
|
||||
thud = np.sin(2 * np.pi * 70 * t) * np.exp(-t * 30)
|
||||
thud += bp_noise(n, 80, 300) * np.exp(-t * 40) * 0.5
|
||||
return dc(yelp * 0.8 + np.roll(thud, int(0.30 * sr)) * 0.9)
|
||||
|
||||
|
||||
def r_kill_bear(): # 熊闷重倒地 0.7s:低吼爆 + 重落地
|
||||
n = int(sr * 0.70)
|
||||
t = np.arange(n) / sr
|
||||
growl = harm_stack(n, 130, 70, [(1, 1.0), (2, 0.5), (3, 0.2)], vib_hz=22, vib_depth=0.10)
|
||||
growl *= (1 - np.exp(-t * 60)) * np.exp(-t * 6)
|
||||
thud = np.sin(2 * np.pi * 52 * t) * np.exp(-t * 16)
|
||||
thud += bp_noise(n, 60, 220) * np.exp(-t * 20) * 0.7
|
||||
return dc(growl * 0.5 + np.roll(thud, int(0.32 * sr)))
|
||||
|
||||
|
||||
def r_skill_spin(): # 旋风斩气旋 0.8s(中央,大动态):加速旋转呼啸 + 顶部爆点
|
||||
n = int(sr * 0.80)
|
||||
t = np.arange(n) / sr
|
||||
y = bp_noise(n, 300, 3000)
|
||||
rate = 3 + 14 * (t / t[-1]) ** 2 # 旋转 AM 加速 3→17Hz
|
||||
am = 0.5 + 0.5 * np.sin(2 * np.pi * np.cumsum(rate) / sr)
|
||||
env = (1 - np.exp(-t * 25)) * np.exp(-np.maximum(t - 0.55, 0) * 9)
|
||||
top = sine_track(n, 900, 1800, 0.25) * np.exp(-np.abs(t - 0.55) * 18) # 气旋顶部哨
|
||||
return dc(y * am * env * 1.1 + top)
|
||||
|
||||
|
||||
def r_breach_thud(): # 营地被撞闷响 + 木栅裂 0.6s(中央)
|
||||
n = int(sr * 0.60)
|
||||
t = np.arange(n) / sr
|
||||
thud = np.sin(2 * np.pi * 55 * t) * np.exp(-t * 13)
|
||||
thud += bp_noise(n, 70, 260) * np.exp(-t * 18) * 0.8
|
||||
# 木栅裂:3 串高频脆响
|
||||
crack = np.zeros(n)
|
||||
for i, st in enumerate([0.05, 0.14, 0.26]):
|
||||
seg_n = n - int(st * sr)
|
||||
seg_t = np.arange(seg_n) / sr
|
||||
c = bp_noise(seg_n, 1400, 4200) * np.exp(-seg_t * (60 - i * 10)) * (0.8 - i * 0.2)
|
||||
place(crack, st, c)
|
||||
return dc(thud + crack * 0.7)
|
||||
|
||||
|
||||
def r_horn_wave(): # 波次号角 0.9s(中央):铜管状谐波簇,起音膨胀尾音下沉
|
||||
n = int(sr * 0.90)
|
||||
t = np.arange(n) / sr
|
||||
f0, f1 = 220, 208 # A3 微沉
|
||||
horn = harm_stack(n, f0, f1, [(1, 1.0), (2, 0.55), (3, 0.30), (4, 0.14)], vib_hz=5.5, vib_depth=0.008)
|
||||
env = (1 - np.exp(-t * 9)) * np.exp(-np.maximum(t - 0.62, 0) * 8)
|
||||
breath = bp_noise(n, 300, 1200) * env * 0.06
|
||||
return dc(horn * env + breath)
|
||||
|
||||
|
||||
def r_join_chime(): # 队友加入 0.4s:双音上行(E5→A5 钟声)
|
||||
n = int(sr * 0.40)
|
||||
y = np.zeros(n)
|
||||
for st, f in [(0.0, 659.25), (0.14, 880.0)]:
|
||||
seg_n = n - int(st * sr)
|
||||
seg_t = np.arange(seg_n) / sr
|
||||
bell = sum(g * np.sin(2 * np.pi * f * r * seg_t) * np.exp(-seg_t * d)
|
||||
for r, g, d in [(1, 1.0, 10), (2.76, 0.25, 16)])
|
||||
place(y, st, bell * 0.6)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_call_whistle(): # 求援哨音 0.6s(座 3 呼喊用):裁判哨式高频颤音
|
||||
n = int(sr * 0.60)
|
||||
t = np.arange(n) / sr
|
||||
w = sine_track(n, 2350, 2450, 1.0, vib_hz=28, vib_depth=0.012)
|
||||
w += 0.2 * sine_track(n, 4700, 4900, 1.0, vib_hz=28, vib_depth=0.012)
|
||||
env = (1 - np.exp(-t * 200)) * np.exp(-np.maximum(t - 0.42, 0) * 12)
|
||||
return dc(w * env * 0.5)
|
||||
|
||||
|
||||
def r_howl_wolf(): # 生成呼啸 0.8s:起-扬-落狼嚎(1-3kHz 中高带)
|
||||
n = int(sr * 0.80)
|
||||
t = np.arange(n) / sr
|
||||
f = 620 + 520 * np.sin(np.pi * np.clip(t / 0.75, 0, 1)) ** 1.4 # 拱形音高
|
||||
ph = 2 * np.pi * np.cumsum(f * (1 + 0.02 * np.sin(2 * np.pi * 6 * t))) / sr
|
||||
howl = np.sin(ph) + 0.3 * np.sin(2 * ph)
|
||||
env = (1 - np.exp(-t * 12)) * np.exp(-np.maximum(t - 0.5, 0) * 7)
|
||||
return dc(howl * env * 0.7)
|
||||
|
||||
|
||||
def r_howl_bear(): # 生成低吼 1.0s:低频粗糙吼声,慢 AM
|
||||
n = int(sr * 1.00)
|
||||
t = np.arange(n) / sr
|
||||
g = harm_stack(n, 95, 75, [(1, 1.0), (2, 0.6), (3, 0.3)], vib_hz=17, vib_depth=0.08)
|
||||
g += bp_noise(n, 150, 500) * 0.25
|
||||
env = (1 - np.exp(-t * 10)) * np.exp(-np.maximum(t - 0.6, 0) * 6)
|
||||
return dc(g * env * 0.8)
|
||||
|
||||
|
||||
ONE_SHOTS = {
|
||||
"atk_whoosh": r_atk_whoosh,
|
||||
"atk_swish": r_atk_swish,
|
||||
"kill_wolf": r_kill_wolf,
|
||||
"kill_bear": r_kill_bear,
|
||||
"skill_spin": r_skill_spin,
|
||||
"breach_thud": r_breach_thud,
|
||||
"horn_wave": r_horn_wave,
|
||||
"join_chime": r_join_chime,
|
||||
"call_whistle": r_call_whistle,
|
||||
"howl_wolf": r_howl_wolf,
|
||||
"howl_bear": r_howl_bear,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- loop 渲染(4s 无缝)
|
||||
def wrap_seamless(y, extra):
|
||||
"""把超出 4s 的尾部回卷叠加到开头,保证循环点连续。"""
|
||||
n = int(sr * 4.0)
|
||||
if len(y) > n:
|
||||
tail = y[n:]
|
||||
y = y[:n]
|
||||
y[:len(tail)] += tail
|
||||
return y
|
||||
|
||||
|
||||
def r_loop_wolf(): # 狼碎步 + 低呜(100-300Hz 低频带;4 步/秒小跑,周期 1s 整除 4s)
|
||||
n = int(sr * 4.0)
|
||||
y = np.zeros(n)
|
||||
step = 0.25 # 4 步/秒,16 步整除 4s,尾 0.09s 不出界无需回卷
|
||||
for i in range(16):
|
||||
L = int(sr * 0.09)
|
||||
tt = np.arange(L) / sr
|
||||
paw = bp_noise(L, 120, 300) * np.exp(-tt * 70) * (1 - np.exp(-tt * 400))
|
||||
paw += np.sin(2 * np.pi * (95 + (i % 2) * 12) * tt) * np.exp(-tt * 60) * 0.5
|
||||
place(y, i * step, paw * (0.55 + 0.15 * ((i * 7) % 3)))
|
||||
t = np.arange(n) / sr
|
||||
# 垫底低呜:恒定 200Hz(4s 整 800 周期)+ 0.25Hz AM(1 周期)+ 4.5Hz 颤音
|
||||
# (18 整周期、积分归零)——所有成分循环点相位对齐,无接缝。
|
||||
whim = sine_track(n, 200, 200, 0.10, vib_hz=4.5, vib_depth=0.05)
|
||||
y += whim * (0.5 + 0.5 * np.sin(2 * np.pi * 0.25 * t)) # 0.25Hz 整周期
|
||||
y = wrap_seamless(y, None)
|
||||
return dc(y)
|
||||
|
||||
|
||||
def r_loop_bear(): # 熊重步 + 喘息(重低频;约 1.5 步/秒,周期 4/3 s 不整除→用 2s 网格 8 步)
|
||||
n = int(sr * 4.0)
|
||||
y = np.zeros(n)
|
||||
for i in range(8): # 每 0.5s 一步(左右交替),8 步整除 4s,尾 0.16s 不出界
|
||||
L = int(sr * 0.16)
|
||||
tt = np.arange(L) / sr
|
||||
hoof = np.sin(2 * np.pi * (58 + (i % 2) * 7) * tt) * np.exp(-tt * 26)
|
||||
hoof += bp_noise(L, 70, 240) * np.exp(-tt * 30) * (1 - np.exp(-tt * 300)) * 0.8
|
||||
place(y, i * 0.5, hoof)
|
||||
t = np.arange(n) / sr
|
||||
# 喘息:恒定 80Hz(4s 整 320 周期)+ 0.5Hz AM/颤音(2 整周期)——循环点相位对齐。
|
||||
breath = sine_track(n, 80, 80, 0.12, vib_hz=0.5, vib_depth=0.06)
|
||||
y += breath * (0.4 + 0.6 * (0.5 + 0.5 * np.sin(2 * np.pi * 0.5 * t)))
|
||||
y = wrap_seamless(y, None)
|
||||
return dc(y)
|
||||
|
||||
|
||||
LOOPS = {
|
||||
"loop_wolf": r_loop_wolf,
|
||||
"loop_bear": r_loop_bear,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 编码 + 真峰值校验
|
||||
def encode(wav_path, base):
|
||||
for args, ext in [(["-c:a", "libvorbis", "-q:a", "3"], ".ogg"),
|
||||
(["-c:a", "aac", "-b:a", "128k"], ".m4a")]:
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", wav_path,
|
||||
"-ar", "44100", *args, base + ext], check=True)
|
||||
|
||||
|
||||
def true_peak_db(path):
|
||||
r = subprocess.run([FFMPEG, "-i", path, "-af", "ebur128=peak=true", "-f", "null", "-"],
|
||||
capture_output=True, text=True)
|
||||
peaks = [float(line.split("Peak:")[1].strip().split()[0])
|
||||
for line in r.stderr.splitlines() if "Peak:" in line and "dBFS" in line]
|
||||
return max(peaks) if peaks else float("nan")
|
||||
|
||||
|
||||
def check_tp(base):
|
||||
return max(true_peak_db(base + ".ogg"), true_peak_db(base + ".m4a"))
|
||||
|
||||
|
||||
def main():
|
||||
report = []
|
||||
problems = []
|
||||
|
||||
for fid, fn in ONE_SHOTS.items():
|
||||
wav_path = os.path.join(OUT, fid + ".wav")
|
||||
base = os.path.join(DST, fid)
|
||||
y = fade(norm(dc(fn())))
|
||||
sf.write(wav_path, stereo(y), sr)
|
||||
encode(wav_path, base)
|
||||
tp = check_tp(base)
|
||||
if tp > ONE_SHOT_TP_CEIL: # MDCT 过冲超限:按超出量降档重编一次再验
|
||||
gain = 10 ** ((ONE_SHOT_TP_CEIL - tp) / 20) * 0.98
|
||||
sf.write(wav_path, stereo(y * gain), sr)
|
||||
encode(wav_path, base)
|
||||
tp2 = check_tp(base)
|
||||
report.append((fid, len(y) / sr, tp, f"降 {20 * np.log10(gain):.1f}dB 重编 -> {tp2:.2f}"))
|
||||
tp = tp2
|
||||
else:
|
||||
report.append((fid, len(y) / sr, tp, ""))
|
||||
if tp > ONE_SHOT_TP_CEIL:
|
||||
problems.append(f"{fid}: 真峰值 {tp:.2f} dBFS 仍超 {ONE_SHOT_TP_CEIL}")
|
||||
|
||||
for fid, fn in LOOPS.items():
|
||||
wav_path = os.path.join(OUT, fid + ".wav")
|
||||
base = os.path.join(DST, fid)
|
||||
y = norm(fn()) # loop:去直流已在渲染内;无首尾淡化(无缝优先)
|
||||
sf.write(wav_path, stereo(y), sr)
|
||||
# 无缝断言:循环点首尾采样差应足够小(回卷后连续)
|
||||
seam = abs(float(y[0]) - float(y[-1]))
|
||||
encode(wav_path, base)
|
||||
tp = check_tp(base)
|
||||
report.append((fid, len(y) / sr, tp, f"loop seam |y0-y1|={seam:.4f}"))
|
||||
if tp > ONE_SHOT_TP_CEIL:
|
||||
problems.append(f"{fid}: loop 真峰值 {tp:.2f} dBFS 超 {ONE_SHOT_TP_CEIL}(建议降档)")
|
||||
|
||||
print(f"{'id':14s} {'dur(s)':>7s} {'TP(dBFS)':>9s} note")
|
||||
for fid, dur, tp, note in report:
|
||||
print(f"{fid:14s} {dur:7.2f} {tp:9.2f} {note}")
|
||||
if problems:
|
||||
print("PROBLEMS:")
|
||||
for p in problems:
|
||||
print(" ", p)
|
||||
return 1
|
||||
print(f"ALL OK: {len(ONE_SHOTS)} one-shot + {len(LOOPS)} loop -> {DST}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+140
-3
@@ -62,6 +62,8 @@ SPEAKERS = {
|
||||
# abyss 默认=DECK 调度(多数行);DIVER 行按 VOICE_SPK["abyss"] 逐句覆盖(radio 同型)
|
||||
"abyss": {"male": "Dylan", "female": "Vivian"},
|
||||
"detective": {"male": "Uncle_Fu", "female": "Serena"},
|
||||
# hunt 旁白双性别(23-hunt-voice-ids.md 头注);bot 喊话固定音色在 HUNT_NPC
|
||||
"hunt": {"male": "Uncle_Fu", "female": "Serena"},
|
||||
}
|
||||
|
||||
# instruct 对:(male, female)。None 表示两性别共用同一 instruct。
|
||||
@@ -1262,7 +1264,125 @@ DETECTIVE_NPC = [
|
||||
("fo_5", "Serena", "冷静干练的女声,验尸官,临床腔,短句平直", "鞋底是干的。当夜,死者没出过后门。"),
|
||||
]
|
||||
|
||||
NPCS = {"rpg": RPG_NPC, "radio": RADIO_NPC, "abyss": ABYSS_NPC, "detective": DETECTIVE_NPC}
|
||||
# ---------------------------------------------------------------- hunt
|
||||
# 《围猎》批次 3(docs/design/23-hunt-voice-ids.md):旁白/系统音 60 句 ×2 性别
|
||||
# (全表同一 instruct 常量/性别,禁语速词;数字全分档整句无拼接;「重听」写「重新听」)
|
||||
# + bot 喊话 24 条固定单音色平铺(HUNT_NPC)+ 键盘变体 V 组 11 对(KEY_LINES["hunt"])。
|
||||
# 异例(23-hunt.md §15 裁决 7):喊话走 sfx 层带 pan,convert 落盘 audio/sfx/ 而非
|
||||
# audio/voice/(生成管线、峰值归一 0.89 与 voice 一致;不滤波)。
|
||||
# H 组头注「×9」为文档笔误,实表 8 句(end_win/end_lose/grade_×4/end_rank/end_again),
|
||||
# 旁白合计 4+11+6+12+3+3+1+8+4+6+2 = 60 ✓。
|
||||
_HUNT = _pair("低沉醇厚的男声,像守夜猎人的领队,沉稳有力",
|
||||
"清亮沉稳的女声,像守夜猎人的领队,冷静中带着鼓舞")
|
||||
HUNT_LINES = [
|
||||
# ---- A 组 · 开场/房间 ----
|
||||
("intro_1", _HUNT, "夜幕降临,野兽在山林里游荡。"),
|
||||
("intro_2", _HUNT, "你们是守夜的猎人,营地就在身后。"),
|
||||
("room_wait", _HUNT, "房间已开好,等同伴到齐。单击,立即开局。"),
|
||||
("room_start", _HUNT, "狩猎开始,先听声音的方向。"),
|
||||
# ---- B 组 · 教学 TEACH ----
|
||||
("tut_rule", _HUNT, "野兽会从八个方向围上来。声音在哪边,就往哪边滑。"),
|
||||
("tut_attack", _HUNT, "听,有狼在靠近。朝声音的方向,滑一下。"),
|
||||
("tut_kill_ok", _HUNT, "好,杀掉了。就是这样。"),
|
||||
("tut_callout", _HUNT, "单击一下,呼喊支援,队友会来帮你。"),
|
||||
("tut_callout_ok", _HUNT, "听到没,同伴回应你了。"),
|
||||
("tut_skill", _HUNT, "双击,旋风斩,清掉身边一圈。"),
|
||||
("tut_skill_cd", _HUNT, "旋风斩有八秒冷却。冷却中双击,重新听威胁。"),
|
||||
("tut_status", _HUNT, "上滑,听营地状态。下滑,打开菜单。"),
|
||||
("tut_breach", _HUNT, "野兽冲进营地,营地就会受损。撑过五波就赢。"),
|
||||
("tut_done", _HUNT, "教学完了。真正的围猎,现在开始。"),
|
||||
("tut_skip", _HUNT, "已跳过教学,开打。"),
|
||||
# ---- C 组 · 波次 ----
|
||||
("wave_band1", _HUNT, "第一波来了,别慌。"),
|
||||
("wave_band2", _HUNT, "第二波,比刚才多。"),
|
||||
("wave_band3", _HUNT, "第三波,有大家伙混在里面。"),
|
||||
("wave_band4", _HUNT, "第四波,更快,更多。"),
|
||||
("wave_band5", _HUNT, "最后一波,全来了!"),
|
||||
("wave_clear", _HUNT, "这一波,守住了。"),
|
||||
# ---- D 组 · 状态链(耐久档 → 波次档 → 猎人数 → 击杀档) ----
|
||||
("hp_band1", _HUNT, "营地固若金汤。"),
|
||||
("hp_band2", _HUNT, "营地受了些损伤。"),
|
||||
("hp_band3", _HUNT, "营地损毁过半。"),
|
||||
("hp_band4", _HUNT, "营地快守不住了。"),
|
||||
("st_wave_a", _HUNT, "还在头几波。"),
|
||||
("st_wave_b", _HUNT, "打到中段了。"),
|
||||
("st_wave_c", _HUNT, "最后一波了。"),
|
||||
("team_3", _HUNT, "三名猎人在守。"),
|
||||
("team_4", _HUNT, "四名猎人全在。"),
|
||||
("kill_band0", _HUNT, "你还没开张。"),
|
||||
("kill_band1", _HUNT, "你小有斩获。"),
|
||||
("kill_band2", _HUNT, "你战果累累。"),
|
||||
# ---- E 组 · 威胁重听(只报数量档,方位由声像承载) ----
|
||||
("threat_none", _HUNT, "近处暂时没有野兽。"),
|
||||
("threat_few", _HUNT, "有野兽贴近了,听方向出手。"),
|
||||
("threat_many", _HUNT, "四周全是动静,快出手!"),
|
||||
# ---- F 组 · 警报(可顶状态/播报类) ----
|
||||
("breach", _HUNT, "有野兽冲进营地了!"),
|
||||
("breach_heavy", _HUNT, "巨兽撞进营地,损伤很重!"),
|
||||
("danger_low", _HUNT, "营地危险了,一只都不能再漏!"),
|
||||
# ---- G 组 · 技能 ----
|
||||
("skill_ready", _HUNT, "旋风斩准备好了。"),
|
||||
# ---- H 组 · 结算 ----
|
||||
("end_win", _HUNT, "天亮了,营地还在。"),
|
||||
("end_lose", _HUNT, "营地守不住了,撤退!"),
|
||||
("grade_s", _HUNT, "评级,S!完美的围猎。"),
|
||||
("grade_a", _HUNT, "评级,A。漂亮。"),
|
||||
("grade_b", _HUNT, "评级,B。守得还行。"),
|
||||
("grade_c", _HUNT, "评级,C。险胜。"),
|
||||
("end_rank", _HUNT, "成绩已记入猎手榜。"),
|
||||
("end_again", _HUNT, "单击,再猎一局。"),
|
||||
# ---- I 组 · 断线/重连 ----
|
||||
("net_lost", _HUNT, "与营地断了联系,正在重连。"),
|
||||
("net_back", _HUNT, "联系恢复了,接着打。"),
|
||||
("net_to_bot", _HUNT, "有猎人离队,老猎手顶上。"),
|
||||
("net_gone", _HUNT, "回不去那场围猎了,回大厅。"),
|
||||
# ---- J 组 · 菜单/帮助 ----
|
||||
("sys_menu", _HUNT, "菜单。单击切换,双击确认。"),
|
||||
("sys_menu_resume", _HUNT, "回到猎场。"),
|
||||
("sys_menu_help", _HUNT, "重新听帮助。"),
|
||||
("sys_menu_home", _HUNT, "退出房间,回大厅。"),
|
||||
("help_main", _HUNT, "朝声音的方向滑,就是出击。单击呼喊支援,双击旋风斩。上滑状态,下滑菜单。"),
|
||||
("settings_hint", _HUNT, "音色、语速、背景音乐,请回大厅修改。"),
|
||||
# ---- K 组 · 向导/纠错(idle-only) ----
|
||||
("gd_invalid", _HUNT, "这个操作现在用不上。"),
|
||||
("gd_idle", _HUNT, "听声音的方向,滑过去。"),
|
||||
]
|
||||
|
||||
# bot 喊话固定单音色平铺(id, speaker, instruct, 台词)→ web/hunt/audio/sfx/<id>.*
|
||||
# 角色 speaker+instruct 恒定(23-hunt-voice-ids.md N 组表):阿岩=Uncle_Fu /
|
||||
# 小筝=Serena / 老周=Eric;座 3 真人呼喊无角色声,用 sfx/call_whistle(裁决 6)。
|
||||
HUNT_NPC = [
|
||||
# 阿岩(座 0,Uncle_Fu)
|
||||
("npc_yan_greet", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "我来了,这边有我。"),
|
||||
("npc_yan_help_call", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "我这侧吃紧,来个人搭把手!"),
|
||||
("npc_yan_help_ack", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "撑住,我来了。"),
|
||||
("npc_yan_cover", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "这边交给我。"),
|
||||
("npc_yan_kill", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "拿下!"),
|
||||
("npc_yan_breach", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "漏了一个,都小心。"),
|
||||
("npc_yan_skill", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "都闪开,看我的!"),
|
||||
("npc_yan_wave", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "新一波,稳住。"),
|
||||
# 小筝(座 1,Serena)
|
||||
("npc_zheng_greet", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "小筝到位,开工啦。"),
|
||||
("npc_zheng_help_call", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "这边太多了,谁来帮一下!"),
|
||||
("npc_zheng_help_ack", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "别慌,我来了!"),
|
||||
("npc_zheng_cover", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "这边我罩着。"),
|
||||
("npc_zheng_kill", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "搞定!"),
|
||||
("npc_zheng_breach", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "哎呀,漏了一个!"),
|
||||
("npc_zheng_skill", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "看我清场!"),
|
||||
("npc_zheng_wave", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "又来一波,打起精神。"),
|
||||
# 老周(座 2,Eric)
|
||||
("npc_zhou_greet", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "老周我守着,放心。"),
|
||||
("npc_zhou_help_call", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "我这边压不住了,来人。"),
|
||||
("npc_zhou_help_ack", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "听见了,这就到。"),
|
||||
("npc_zhou_cover", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "这边交给我吧。"),
|
||||
("npc_zhou_kill", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "嗯,解决了。"),
|
||||
("npc_zhou_breach", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "啧,溜进去一只。"),
|
||||
("npc_zhou_skill", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "让让,老骨头活动活动。"),
|
||||
("npc_zhou_wave", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "听,又来了。"),
|
||||
]
|
||||
|
||||
NPCS = {"rpg": RPG_NPC, "radio": RADIO_NPC, "abyss": ABYSS_NPC, "detective": DETECTIVE_NPC,
|
||||
"hunt": HUNT_NPC}
|
||||
|
||||
# ---------------------------------------------------------------- 键盘变体(P1 路线 a)
|
||||
# 「生效输入模式=键盘」的玩家听按键措辞:向左滑→按左方向键(四向同理,WASD 不进
|
||||
@@ -1481,6 +1601,20 @@ KEY_LINES = {
|
||||
("cf_confirm", "就这句?空格,定下来。"),
|
||||
("settle_hook", "同一场雨里,还有别的案子。按空格,接新案。"),
|
||||
],
|
||||
# hunt:V 组 11 对(23-hunt-voice-ids.md 文末;措辞「方向键/空格/回车」同 dungeon 规范)。
|
||||
"hunt": [
|
||||
("room_wait", "房间已开好,等同伴到齐。按空格,立即开局。"),
|
||||
("tut_rule", "野兽会从八个方向围上来。声音在哪边,就按哪边方向键。"),
|
||||
("tut_attack", "听,有狼在靠近。朝声音的方向,按方向键。"),
|
||||
("tut_callout", "按空格,呼喊支援,队友会来帮你。"),
|
||||
("tut_skill", "按回车,旋风斩,清掉身边一圈。"),
|
||||
("tut_skill_cd", "旋风斩有八秒冷却。冷却中按回车,重新听威胁。"),
|
||||
("tut_status", "按上方向键,听营地状态。按下方向键,打开菜单。"),
|
||||
("sys_menu", "菜单。按空格切换,按回车确认。"),
|
||||
("help_main", "朝声音的方向按方向键,就是出击。按空格呼喊支援,按回车旋风斩。上方向键状态,下方向键菜单。"),
|
||||
("end_again", "按空格,再猎一局。"),
|
||||
("gd_idle", "听声音的方向,按方向键。"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1558,6 +1692,7 @@ GAMES = {
|
||||
"dungeon": DUNGEON_LINES,
|
||||
"abyss": ABYSS_LINES,
|
||||
"detective": DETECTIVE_LINES,
|
||||
"hunt": HUNT_LINES,
|
||||
}
|
||||
|
||||
|
||||
@@ -1821,13 +1956,15 @@ def convert(games):
|
||||
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>.*;radio 按 NPC_BANDPASS 带通)
|
||||
# NPC 平铺(staging <game>/npc/ → audio/voice/<id>.*;radio 按 NPC_BANDPASS 带通)。
|
||||
# 异例:hunt 喊话走 sfx 层带 pan(23-hunt.md §8 R2 / §15 裁决 7),落 audio/sfx/。
|
||||
npc_layer = "sfx" if game == "hunt" else "voice"
|
||||
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)
|
||||
base = os.path.join(ROOT, "web", game, "audio", npc_layer, fid)
|
||||
af = npc_af(game, fid)
|
||||
af_args = ["-af", af] if af else []
|
||||
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, *af_args,
|
||||
|
||||
@@ -173,3 +173,22 @@ deck 低响度淡入由游戏层控制;18-detective.md §8.1 明确 3 首 -20
|
||||
abyss -20.5 ~ -21.9dB;detective -20.2 ~ -21.7dB,与在库 BGM 同量级。
|
||||
雨声底按 18 号文档烤进 detective 曲目(省第三声流)。无节奏关键曲目,未跑 bpm_precise。
|
||||
flac 原件存 tools/mureka_out/{abyss,detective}-*/。
|
||||
|
||||
## 2026-09-13(批次 3:hunt《围猎》)
|
||||
|
||||
| # | task id | 用途 | prompt | 状态 | 花费 |
|
||||
|---|---|---|---|---|---|
|
||||
| 37 | 160549425905665 | hunt bgm/hunt(2 候选) | Tense night hunt drums, low tribal percussion 100 bpm, ominous strings pulses, cold wind, no vocals, seamless loop | succeeded | $0.03 |
|
||||
| 38 | 160549591842821 | hunt bgm/menu(2 候选) | Night camp ambient pad, distant crickets and low wind, warm fireplace crackle subtle, calm anticipation, no vocals, seamless loop | succeeded | $0.03 |
|
||||
|
||||
**本轮花费:$0.06。累计:$1.14。**
|
||||
|
||||
选用(拿不准选时长更接近 90s 的候选):hunt=候选 0(192.6s,choice 160549545639938);
|
||||
menu=候选 0(177.3s,choice 160549690146817)。备选未用:hunt 候选 1(210.2s)/
|
||||
menu 候选 1(207.0s)。flac 原件存 tools/mureka_out/hunt-{hunt,menu}/。
|
||||
|
||||
后处理(脚本 tools/gen_hunt_bgm.sh,detective 同款工艺):强 onset 起点 → 93s 裁剪 +
|
||||
首尾 3s acrossfade 无缝循环 → loudnorm I=-20/TP=-2/LRA=11(23-hunt.md §10.1 两首均
|
||||
-20 LUFS;结算 0.5 音量由游戏层控制)→ ogg q3 + m4a 128k。成品均 90.00s。
|
||||
hunt 无节拍网格玩法,100bpm 仅为 prompt 特征词,未跑 bpm_precise(dungeon 先例)。
|
||||
两条任务一次成功无重试。
|
||||
|
||||
Reference in New Issue
Block a user