feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账) - 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产) - 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿 - radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema) - dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链) - 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿) - 大厅:新增 radio/dungeon 卡片 + GAMES 数组 - harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记 - R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份 - R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# 《不眠频率》BGM 后处理(批次 1):Mureka flac 候选 -> 90s 无缝循环 ogg+m4a。
|
||||
# 用法: bash web/radio/gen/gen_bgm.sh (flac 原件在 tools/mureka_out/,不入库)
|
||||
#
|
||||
# 工艺(tools/mureka_log.md R4/R6 同款):
|
||||
# 首个强 onset 起点(silenceremove,-35dB 门限)→ 93s 裁剪 → 首尾 3s acrossfade(tri)
|
||||
# 无缝循环 → loudnorm(onair/bridge/ending I=-20;menu pad I=-24,10 号文档 §1.2)
|
||||
# → aresample 44.1kHz → ogg libvorbis q3 + m4a aac 128k,落 web/radio/audio/bgm/。
|
||||
# Mureka 任务 id / 选用 choice / 成本见 tools/mureka_log.md「批次 1」节;无节奏关键曲目,
|
||||
# 不需要 bpm_precise。
|
||||
set -euo pipefail
|
||||
cd /home/xsl/SoundGame
|
||||
|
||||
TMP=tools/bgm_post_wav/radio # 中间 wav(不入库,可由本脚本复现)
|
||||
DST=web/radio/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 的那支(拿不准原则),详见 mureka_log.md
|
||||
post tools/mureka_out/radio-onair/158551062544386_0.flac onair -20
|
||||
post tools/mureka_out/radio-bridge/158551207313409_0.flac bridge -20
|
||||
post tools/mureka_out/radio-menu/158551383277574_0.flac menu -24
|
||||
post tools/mureka_out/radio-ending/158551538401281_1.flac ending -20
|
||||
|
||||
echo "== radio bgm done: $(ls "$DST" | wc -l) files =="
|
||||
@@ -0,0 +1,99 @@
|
||||
"""web/radio 封面:1080x1920,深夜电台主题——深蓝夜色底、红灯「ON AIR」、
|
||||
声波弧线与竖排标题《不眠频率》。可复现脚本(固定 seed,无外部网络依赖)。
|
||||
|
||||
用法:python3 web/radio/gen/gen_cover.py
|
||||
(或 .venv-tts/bin/python web/radio/gen/gen_cover.py)
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
W, H = 1080, 1920
|
||||
FONT_CANDIDATES = [
|
||||
"/home/xsl/SoundGame/web/rhythm/gen/NotoSansSC-Bold.ttf",
|
||||
"/home/xsl/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf",
|
||||
]
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cover.png")
|
||||
|
||||
|
||||
def load_font(size):
|
||||
for p in FONT_CANDIDATES:
|
||||
if os.path.exists(p):
|
||||
return ImageFont.truetype(p, size)
|
||||
raise SystemExit("未找到中文字体,请放一份 NotoSansSC-Bold.ttf 到 web/rhythm/gen/")
|
||||
|
||||
|
||||
def main():
|
||||
rng = random.Random(915) # 固定 seed:可复现
|
||||
img = Image.new("RGB", (W, H), (8, 10, 24))
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 背景竖向微渐变:深夜蓝 → 微亮天际(黎明前的电台夜班)
|
||||
for y in range(H):
|
||||
t = y / H
|
||||
d.line([(0, y), (W, y)],
|
||||
fill=(int(8 + 10 * t), int(10 + 12 * t), int(24 + 26 * t)))
|
||||
|
||||
# 远景声波弧线(电台频率的具象,模糊压暗做背景)
|
||||
wave = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
wd = ImageDraw.Draw(wave)
|
||||
palette = [(94, 234, 212), (129, 140, 248), (244, 114, 182)]
|
||||
for i in range(22):
|
||||
cx, cy = rng.randint(0, W), rng.randint(0, H)
|
||||
r = rng.randint(80, 360)
|
||||
col = palette[i % len(palette)]
|
||||
wd.ellipse([cx - r, cy - r, cx + r, cy + r],
|
||||
outline=col + (rng.randint(8, 26),), width=rng.randint(2, 4))
|
||||
wave = wave.filter(ImageFilter.GaussianBlur(1.6))
|
||||
img.paste(wave, (0, 0), wave)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 中央:红色「ON AIR」指示灯 + 同心圆话筒波纹
|
||||
cx, cy = W // 2, 620
|
||||
for i, (r, alpha) in enumerate([(300, 46), (224, 78), (152, 118)]):
|
||||
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=(248, 113, 113, alpha), width=8 - i * 2)
|
||||
img.paste(ring, (0, 0), ring)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.ellipse([cx - 64, cy - 64, cx + 64, cy + 64], fill=(220, 38, 38))
|
||||
d.ellipse([cx - 30, cy - 30, cx + 30, cy + 30], fill=(252, 165, 165))
|
||||
f_lamp = load_font(52)
|
||||
bbox = d.textbbox((0, 0), "ON AIR", font=f_lamp)
|
||||
d.text((cx - (bbox[2] - bbox[0]) / 2 - bbox[0], cy - (bbox[3] - bbox[1]) / 2 - bbox[1] - 210),
|
||||
"ON AIR", font=f_lamp, fill=(248, 113, 113))
|
||||
|
||||
# 标题(居中)与副题
|
||||
f_title = load_font(168)
|
||||
f_sub = load_font(54)
|
||||
f_small = load_font(38)
|
||||
|
||||
def ctext(y, s, font, fill, spacing=8):
|
||||
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(1020, "不眠频率", f_title, (240, 244, 255), spacing=14)
|
||||
ctext(1260, "深夜电台热线 · 接住五个陌生人的夜晚", f_sub, (148, 163, 184))
|
||||
|
||||
# 手势图例(极简两行)
|
||||
hints = ["单击 接听 / 跳句", "左右滑 抉择", "上滑 状态", "下滑 菜单", "双击 重听"]
|
||||
for i, s in enumerate(hints):
|
||||
col, row = i % 3, i // 3
|
||||
d.text((110 + col * 300, 1420 + row * 76), s, font=f_small, fill=(110, 130, 160))
|
||||
|
||||
ctext(1700, "午夜零点,热线灯亮。你说什么,由你决定。", f_small, (90, 105, 135))
|
||||
|
||||
img.save(OUT)
|
||||
print(OUT, img.size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《不眠频率》SYN 程序合成音效(批次 1 + R2 F3,15-radio.md §5.2 标注「程序合成」的条目)。
|
||||
|
||||
R2 起共 3 件(其余 11 件为 Stable Audio 3 条目,HF token 未配置,本轮跳过——见批次资产报告):
|
||||
ui_confirm 菜单/接听确认(同全局先例:storyline gen_sfx.py 的 880->1320Hz 双音)
|
||||
ui_menu 菜单呼出(同全局先例:330+495Hz 软音)
|
||||
phone_ring RING 铃声循环垫(R2 F3:440+480Hz 柔和双音一响 1.8s + 静默 3.4s,
|
||||
循环周期 5.2s ≥4s 烘进文件本身;无缝 loop 规格:逐声道去直流、
|
||||
首尾**不做淡化**、循环边界静音校验;游戏侧 src.loop=true 循环播放,
|
||||
接听即停——SA3 真铃声到位后可整文件替换,接口不变)
|
||||
|
||||
可复现:本脚本入库,一键出 web/radio/audio/sfx/ui_{confirm,menu,phone_ring}.{ogg,m4a};
|
||||
中间 wav 在 gen/staging/sfx/(不入库)。规格:ogg libvorbis q4 44.1kHz + m4a aac 128k;
|
||||
one-shot 真峰值 <= -3dBFS(解码回读法校验,源归一 -4dBFS 留 MDCT 过冲余量)。
|
||||
|
||||
用法:.venv-tts/bin/python web/radio/gen/gen_sfx.py (纯 CPU,无 GPU 依赖)
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
SR = 44100
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
STAGING = os.path.join(HERE, "staging", "sfx")
|
||||
DST = os.path.join(HERE, "..", "audio", "sfx")
|
||||
FFMPEG = "/home/xsl/SoundGame/tools/bin/ffmpeg"
|
||||
PEAK_SRC = 10 ** (-4.0 / 20) # 源归一 -4dBFS(有损编码过冲余量)
|
||||
PEAK_LIMIT = 10 ** (-3.0 / 20) # 解码回读上限 -3dBFS
|
||||
os.makedirs(STAGING, exist_ok=True)
|
||||
os.makedirs(DST, exist_ok=True)
|
||||
|
||||
|
||||
def t(d):
|
||||
return np.arange(int(SR * d)) / SR
|
||||
|
||||
|
||||
def env_exp(d, attack, release):
|
||||
n = int(SR * d)
|
||||
e = np.ones(n)
|
||||
na, nr = max(1, int(SR * attack)), max(1, int(SR * release))
|
||||
e[:na] = np.linspace(0, 1, na)
|
||||
e[-nr:] *= np.linspace(1, 0, nr)
|
||||
return e
|
||||
|
||||
|
||||
def save(name, sig):
|
||||
"""立体声存。工艺同 tools/fix_clicks.py:逐声道去直流 → 首尾 8ms cos² 淡入淡出 → 峰值归一 -4dBFS。"""
|
||||
if sig.ndim == 1:
|
||||
sig = np.stack([sig, sig], axis=1)
|
||||
sig = sig - np.mean(sig, axis=0, keepdims=True)
|
||||
nf = max(1, int(SR * 0.008))
|
||||
fade = 0.5 * (1 - np.cos(np.pi * np.arange(nf) / nf))
|
||||
sig = sig.astype(np.float64).copy()
|
||||
sig[:nf] *= fade[:, None]
|
||||
sig[-nf:] *= fade[::-1][:, None]
|
||||
sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC
|
||||
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
|
||||
|
||||
|
||||
def save_loop(name, sig):
|
||||
"""无缝 loop 立体声存(R2 F3):逐声道去直流 → **不做首尾淡化** → 峰值归一 -4dBFS。
|
||||
循环边界必须落在静音(信号首尾各留 ≥0.3s 零块),loop 拼接零接缝零爆点。"""
|
||||
if sig.ndim == 1:
|
||||
sig = np.stack([sig, sig], axis=1)
|
||||
sig = sig - np.mean(sig, axis=0, keepdims=True)
|
||||
sig = sig.astype(np.float64).copy()
|
||||
sig = sig / (np.max(np.abs(sig)) + 1e-9) * PEAK_SRC
|
||||
sf.write(os.path.join(STAGING, name + ".wav"), sig.astype(np.float32), SR)
|
||||
|
||||
|
||||
# ---- ui_confirm:菜单/接听确认(全局先例双音;尾部留 50ms 静音给编码器软着陆) ----
|
||||
d1, d2 = 0.09, 0.14
|
||||
a = np.sin(2 * np.pi * 880 * t(d1)) * env_exp(d1, 0.005, 0.003)
|
||||
b = np.sin(2 * np.pi * 1320 * t(d2)) * env_exp(d2, 0.005, 0.012)
|
||||
save("ui_confirm", np.concatenate([a, np.zeros(int(SR * 0.02)), b, np.zeros(int(SR * 0.05))]))
|
||||
|
||||
# ---- ui_menu:菜单呼出(全局先例软音) ----
|
||||
d = 0.35
|
||||
x = t(d)
|
||||
save("ui_menu", np.concatenate([
|
||||
(np.sin(2 * np.pi * 330 * x) + 0.5 * np.sin(2 * np.pi * 495 * x)) * env_exp(d, 0.02, 0.08),
|
||||
np.zeros(int(SR * 0.05))]))
|
||||
|
||||
# ---- phone_ring:RING 铃声循环垫(R2 F3)----
|
||||
# 440+480Hz 经典回铃双频(柔和纯弦叠加),一响 1.8s + 静默 3.4s = 5.2s 循环周期
|
||||
# (≥4s,原 RING_INTERVAL_MS 节奏烘进文件)。无缝 loop:起振 60ms 线性软起音
|
||||
# (首样本严格过零)+ 500ms 缓释,循环拼接点两侧均为静音零样本。
|
||||
_ding, _rest = 1.8, 3.4
|
||||
_xr = t(_ding)
|
||||
_ring = (np.sin(2 * np.pi * 440.0 * _xr) + np.sin(2 * np.pi * 480.0 * _xr)) * 0.5
|
||||
_ring = _ring * env_exp(_ding, 0.06, 0.5)
|
||||
save_loop("phone_ring", np.concatenate([_ring, np.zeros(int(SR * _rest))]))
|
||||
|
||||
|
||||
def encode(name, loop=False):
|
||||
"""staging wav -> ogg q4 + m4a 128k,解码回读校验:逐声道真峰值 <= -3dBFS、|直流| <= 0.001。
|
||||
工艺:源尾部垫 50ms 静音(编码器软着陆,one-shot)+ highpass=f=30(抑制 MDCT/AAC 解码直流再生)。
|
||||
loop=True(无缝循环垫)追加边界校验:解码后首/末样本逐声道 |amp| <= -60dBFS——
|
||||
循环拼接点两侧均为零样本,src.loop=true 拼接零爆点(不做首尾淡化,见 save_loop)。"""
|
||||
src = os.path.join(STAGING, name + ".wav")
|
||||
for ext, cmd in [("ogg", ["-c:a", "libvorbis", "-q:a", "4"]),
|
||||
("m4a", ["-c:a", "aac", "-b:a", "128k"])]:
|
||||
out = os.path.join(DST, f"{name}.{ext}")
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src, "-af", "highpass=f=30",
|
||||
"-ar", "44100", *cmd, out], check=True)
|
||||
chk = "/tmp/_sfx_qc.wav"
|
||||
subprocess.run([FFMPEG, "-y", "-v", "error", "-i", out, "-c:a", "pcm_f32le", chk], check=True)
|
||||
y, sr = sf.read(chk, always_2d=True)
|
||||
tp = 20 * np.log10(np.max(np.abs(y)) + 1e-9) # 逐声道峰值
|
||||
assert tp <= -3.0, f"{name}.{ext}: 真峰值 {tp:.2f}dBFS 超限"
|
||||
assert abs(float(np.mean(y.mean(axis=1)))) <= 0.001, f"{name}.{ext}: 直流超限"
|
||||
if loop:
|
||||
edge = 10 ** (-60.0 / 20)
|
||||
assert float(np.max(np.abs(y[0, :]))) <= edge, f"{name}.{ext}: 循环头样本非静音"
|
||||
assert float(np.max(np.abs(y[-1, :]))) <= edge, f"{name}.{ext}: 循环尾样本非静音"
|
||||
print(f" loop {y.shape[0] / sr:.2f}s 边界静音 ✓")
|
||||
print(f"[ok] {name}.{ext} peak={tp:.2f}dBFS")
|
||||
|
||||
|
||||
for _n in ["ui_confirm", "ui_menu"]:
|
||||
encode(_n)
|
||||
encode("phone_ring", loop=True)
|
||||
print("radio SYN sfx done")
|
||||
Reference in New Issue
Block a user