diff --git a/audio/batch_narrator_tts.py b/audio/batch_narrator_tts.py new file mode 100644 index 0000000..1962ad8 --- /dev/null +++ b/audio/batch_narrator_tts.py @@ -0,0 +1,1248 @@ +#!/usr/bin/env python3 +""" +盲游 · 旁白 TTS 批量生产脚本 — VoxCPM2 版 + +用法: + # 先确保 VoxCPM2 服务已启动: + bash audio/check_tts_env.sh + + # 生产所有游戏旁白 + python audio/batch_narrator_tts.py --source voice_mp3 + + # 只生产指定游戏 + python audio/batch_narrator_tts.py --source voice_mp3 --game shouye + + # 只生产 P0(narrate/tap/dt/intro) + python audio/batch_narrator_tts.py --source voice_mp3 --priority p0 + + # 干跑(只看任务列表) + python audio/batch_narrator_tts.py --source voice_mp3 --dry-run + +输出目录: + audio/mp3/narrator/{game_id}/{game_id}_{scene_id}_{type}.mp3 + +参考音色目录(需预先准备): + voice_mp3/narrator/{game_id}/ 或 voice/narrator/{game_id}/ +""" +import os +import sys +import time +import argparse +import subprocess +import tempfile +import base64 +import glob +import shutil + +import requests + +# ────────────────────────────────────────────────────────────── +# 配置 +# ────────────────────────────────────────────────────────────── +PROJECT_DIR = "/home/xsl/blind" +OUT_BASE = f"{PROJECT_DIR}/audio/mp3/narrator" + +HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1") +PORT = os.environ.get("VOXCPM_PORT", "8000") +BASE_URL = f"http://{HOST}:{PORT}" + +# ────────────────────────────────────────────────────────────── +# 旁白角色配置 +# dir: 在 voice_mp3/narrator/ 或 voice/narrator/ 下的子目录 +# speed: 语速倍率(通过 ffmpeg atempo 后处理) +# priority: 角色级别,用于 --game 过滤 +# ────────────────────────────────────────────────────────────── +NARRATORS = { + "linxia": { + "dir": "linxia", + "speed": 0.85, + "note": "纪录片旁白·女声·成熟·平静中带悲悯", + }, + "yinian": { + "dir": "yinian", + "speed": 0.80, + "note": "内心独白·女声·接近耳语但清晰", + }, + "shouye": { + "dir": "shouye", + "speed": 0.90, + "note": "对讲机提示·男声·中年感·略带沙哑", + }, + "houzhen": { + "dir": "houzhen", + "speed": 0.95, + "note": "时间之声·播音腔·完全中性·精确", + }, + "yisheng": { + "dir": "yisheng", + "speed": 0.92, + "note": "手机系统语音·女声·偏年轻·咬字清晰", + }, + "fu": { + "dir": "fu", + "speed": 0.75, + "note": "梦里的声音·女声·气声·偏空灵", + }, +} + +# ────────────────────────────────────────────────────────────── +# 台词表 +# 格式: (输出文件名不含扩展名, 旁白角色key, 台词, 优先级) +# 优先级: p0=核心 p1=结局 p2=条件变体 p3=动态分片 +# ────────────────────────────────────────────────────────────── +LINES = [ + + # ══════════════════════════════════════════════════════════ + # 《林夏》 — 纪录片旁白 + # ══════════════════════════════════════════════════════════ + + # 开场 + ("linxia/linxia_intro", "linxia", + "今晚九点。你是林夏,二十四岁,在北京独居。手机屏幕碎了,但还能用。" + "今晚有十六条消息,你来决定接谁,不接谁。单击接听或回复,双击拒接或忽略。", + "p0"), + + # 消息01 · 妈妈来电 + ("linxia/linxia_msg01_narrate", "linxia", + "晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。", + "p0"), + ("linxia/linxia_msg01_tap", "linxia", + "你接了。妈妈的声音从电话里传来,带着一点日常的关心,问你今天怎么样,吃了没有。" + "你说还好。挂掉之后,房间里又安静了。", + "p0"), + ("linxia/linxia_msg01_dt", "linxia", + "铃声响了又停。妈妈没再打来。", + "p0"), + + # 消息02 · 小美语音×3 + ("linxia/linxia_msg02_notify", "linxia", + "九点一刻。微信震了两下。小美给你发来三条语音消息。", + "p0"), + ("linxia/linxia_msg02_notify_dt", "linxia", + "你把手机屏幕朝下放着。消息还在那里,但你不想听。", + "p0"), + ("linxia/linxia_msg02_play_narrate", "linxia", + "小美说了很多,大意是关于上次那件事,她一直想跟你说清楚,她说她只是太担心你了。" + "最后她问:你最近还好吗?", + "p0"), + ("linxia/linxia_msg02_play_tap", "linxia", + "你回了消息。小美很快回说:谢谢你愿意回我。", + "p0"), + ("linxia/linxia_msg02_play_dt", "linxia", + "你听完了,但没有回。消息就这样放着。", + "p0"), + + # 消息03 · 外卖来电 + ("linxia/linxia_msg03_narrate", "linxia", + "九点二十分。来电话了,是一个陌生号码——是外卖小哥,说你的外卖到了,问你在哪层。", + "p0"), + ("linxia/linxia_msg03_tap", "linxia", + "你接了,告诉他楼层,外卖放门口就好。过了一会儿,门铃响了,外卖到了。", + "p0"), + ("linxia/linxia_msg03_dt", "linxia", + "你没接。不知道外卖最后怎么样了。", + "p0"), + + # 消息04 · 阿哲语音 + ("linxia/linxia_msg04_notify", "linxia", + "九点半。微信震了一下。阿哲——你的前男友——给你发来一条语音。", + "p0"), + ("linxia/linxia_msg04_notify_dt", "linxia", + "你没有打开。阿哲的消息放在那里。", + "p0"), + ("linxia/linxia_msg04_play_narrate", "linxia", + "阿哲说,他有一件东西落在你这里了,问你方便不方便他过来拿一下。" + "语气很平,不像是有别的意思。", + "p0"), + ("linxia/linxia_msg04_play_tap", "linxia", + "你回了条消息说可以。阿哲说谢谢,说到时候再联系。你放下手机,有点说不清的感觉。", + "p0"), + ("linxia/linxia_msg04_play_dt", "linxia", + "你没有回。消息就这样。", + "p0"), + + # 消息05 · HR王姐 + ("linxia/linxia_msg05_notify", "linxia", + "九点四十五分。HR王姐发来语音,是关于下个月调岗的事,问你有没有空聊一聊。", + "p0"), + ("linxia/linxia_msg05_notify_dt", "linxia", + "你把消息标记了已读,回头再说。", + "p0"), + ("linxia/linxia_msg05_play_narrate", "linxia", + "王姐说有个岗位的变动,你可能会受到影响,想提前跟你说一声,让你有个准备。" + "语气是友善的,像是帮你。", + "p0"), + ("linxia/linxia_msg05_play_tap", "linxia", + "你回了消息说谢谢告知,改天约时间聊。王姐说好的。", + "p0"), + ("linxia/linxia_msg05_play_dt", "linxia", + "你没有回。这件事先放一放。", + "p0"), + + # 消息06 · 安安邀约 + ("linxia/linxia_msg06_notify", "linxia", + "晚上十点。安安发来语音,她说她们今晚在酒吧,问你要不要过来,说来了就是了,不用提前说。", + "p0"), + ("linxia/linxia_msg06_notify_dt", "linxia", + "你没有打开消息。今晚不想动。", + "p0"), + ("linxia/linxia_msg06_play_narrate", "linxia", + "安安的声音很热闹,背景里有音乐。她说来嘛来嘛,好久没见了,就几个人,很轻松的那种。", + "p0"), + ("linxia/linxia_msg06_play_tap", "linxia", + "你回了条消息说行,等等到。安安说耶!你站起来换了件外套,但后来不知道为什么又坐下了。", + "p0"), + ("linxia/linxia_msg06_play_dt", "linxia", + "你回了消息说今晚状态不太好,下次吧。安安说没事,下次一定。", + "p0"), + + # 消息07 · 寝室群×5 + ("linxia/linxia_msg07_notify", "linxia", + "晚上十点一刻。寝室群活了,连着震了好几下,是大学室友们在聊。你们已经很久没联系了。", + "p0"), + ("linxia/linxia_msg07_notify_dt", "linxia", + "你把群消息静音了。不是今晚。", + "p0"), + ("linxia/linxia_msg07_play_narrate", "linxia", + "她们在聊上周聚会的事,有人说想你了,有人问你最近怎么样,有人说快来啊。" + "那种熟悉的语气,你有点想念。", + "p0"), + ("linxia/linxia_msg07_play_tap", "linxia", + "你发了一条消息,说最近还好,想你们了。群里一下子热闹起来,大家说你终于出现了。" + "你看着那些消息,嘴角动了一下。", + "p0"), + ("linxia/linxia_msg07_play_dt", "linxia", + "你看完了,但没有回复。你把手机放下。那种热闹是她们的,你在这边。", + "p0"), + + # 消息08 · 未知号码 + ("linxia/linxia_msg08_narrate", "linxia", + "晚上十点半。手机响了,来电显示:未知号码。铃声有点不一样,低沉的那种。", + "p0"), + ("linxia/linxia_msg08_tap", "linxia", + "你接了。里面没有声音,只有轻微的呼吸声,然后断了。你不知道是谁,心跳加快了一下。", + "p0"), + ("linxia/linxia_msg08_dt", "linxia", + "未知号码,不接。铃声停了。", + "p0"), + + # 消息09 · 小美醉酒×4 + ("linxia/linxia_msg09_notify", "linxia", + "十点四十五分。小美又发来消息,连续四条。语音很长,而且发送时间是一条接一条。", + "p0"), + ("linxia/linxia_msg09_notify_dt", "linxia", + "消息放那里,你没有打开。", + "p0"), + ("linxia/linxia_msg09_play_narrate", "linxia", + "小美喝多了,说话有点不清楚,但每一句都很认真。她说她一直有件事想跟你说:" + "那年她告诉了别人,是她不对,她知道,她一直很愧疚,她说她怕你恨她。", + "p0"), + ("linxia/linxia_msg09_play_tap", "linxia", + "你打了几个字发过去。小美隔了很久才回,说谢谢你,然后说她要先睡了,明天聊。你把手机放下。", + "p0"), + ("linxia/linxia_msg09_play_dt", "linxia", + "你没有回。这件事太重了,不是今晚能说清楚的。", + "p0"), + + # 消息10 · 妈妈文字 + ("linxia/linxia_msg10_narrate", "linxia", + "深夜十一点刚过。妈妈发来一条文字消息,就五个字:睡了吗,夏夏。", + "p0"), + ("linxia/linxia_msg10_tap", "linxia", + "你回了:还没呢,妈你睡吧。妈妈说好,早点睡,然后发了个晚安的表情。" + "你盯着那个表情看了一会儿。", + "p0"), + ("linxia/linxia_msg10_dt", "linxia", + "你没有回妈妈的消息。她那边大概以为你已经睡了。", + "p0"), + + # 消息11 · 阿哲关键来电 + ("linxia/linxia_msg11_narrate", "linxia", + "十一点一刻。手机响了,专属铃声——是阿哲打来的。", + "p0"), + ("linxia/linxia_msg11_tap", "linxia", + "阿哲说他就在附近,问你方不方便他上来取一下那件东西。他的声音平静,但停顿了一下。", + "p0"), + ("linxia/linxia_msg11_dt", "linxia", + "铃声停了。阿哲没再打来。", + "p0"), + ("linxia/linxia_msg11_choice_narrate", "linxia", + "他在等你说行不行。", + "p0"), + ("linxia/linxia_msg11_choice_tap", "linxia", + "你说好,上来吧。阿哲说谢谢,说马上到。你放下手机,在房间里站了一会儿。", + "p0"), + ("linxia/linxia_msg11_choice_dt", "linxia", + "你说今晚不太方便。阿哲沉默了一秒,说行,那之后再说。挂掉了。", + "p0"), + + # 消息12 · 周南初次语音 + ("linxia/linxia_msg12_notify", "linxia", + "十一点半。一个名字出现在屏幕上:周南。你有三年没联系了,他突然发来一条语音。", + "p0"), + ("linxia/linxia_msg12_notify_dt", "linxia", + "你没有打开周南的消息。三年了,不急这一会儿。", + "p0"), + ("linxia/linxia_msg12_play_narrate", "linxia", + "周南说:林夏你好,是我周南,不知道你还记不记得,就是突然想起你," + "也不知道你还在不在这个号,就发一条。他的声音还是老样子,只是低沉了一些。", + "p0"), + ("linxia/linxia_msg12_play_tap", "linxia", + "你回了:记得,你好啊。消息发出去了,你盯着那两个字,觉得说多说少都不对,就这样。", + "p0"), + ("linxia/linxia_msg12_play_dt", "linxia", + "你听完了,没有回。也许三年的空白不是一条消息能填上的。", + "p0"), + + # 消息13 · 周南长语音×4 + ("linxia/linxia_msg13_notify", "linxia", + "深夜十一点四十五分。周南又发来四条,比刚才长很多。", + "p0"), + ("linxia/linxia_msg13_notify_dt", "linxia", + "你把手机放到一边了。今晚已经够多了。", + "p0"), + ("linxia/linxia_msg13_play_narrate", "linxia", + "周南说了他这十年:换了几个城市,做了几份工作,有一段感情,然后结束了。" + "他说他今晚喝了一点酒,就想到你,想到你们高中的那些事,说他一直不知道当年为什么就断了联系。" + "然后他问:你在北京过得好吗?", + "p0"), + ("linxia/linxia_msg13_play_tap", "linxia", + "你想了很久,然后把这段时间的事告诉了周南。说不上好不好,就是在过。" + "他说谢谢你告诉我这些。你们聊了很久。", + "p0"), + ("linxia/linxia_msg13_play_dt", "linxia", + "你回了说还好,然后问了他现在在哪里。你们就这样聊着,没有太深入,但很平静。", + "p0"), + + # 消息14 · 妈妈第二次来电(条件分支) + ("linxia/linxia_msg14_narrate", "linxia", + "凌晨零点一刻。妈妈又打来电话了,这么晚——她还没睡。", + "p0"), + ("linxia/linxia_msg14_tap_a", "linxia", + "你接了。妈妈说:我就是想再听到你的声音。你们聊了一会儿,说了一些很日常的话,但你都记住了。", + "p2"), + ("linxia/linxia_msg14_tap_b", "linxia", + "你接了。妈妈说:没事,就是问问你睡了没。你说没呢,妈你睡吧。她说想听你的声音。", + "p2"), + ("linxia/linxia_msg14_dt", "linxia", + "太晚了,挂掉了。妈妈没有再打来。", + "p0"), + + # 消息15 · 时光胶囊 + ("linxia/linxia_msg15_narrate", "linxia", + "凌晨零点半。手机弹出一个系统通知:三年前的今天,你给三年后的自己录了一条语音,今天到了。", + "p0"), + ("linxia/linxia_msg15_tap", "linxia", + "你听到了三年前的自己的声音。她说:如果你在听这条消息,说明你撑过来了。" + "不管现在怎么样,你撑过来了。然后她停顿了很久,说:辛苦了。", + "p0"), + ("linxia/linxia_msg15_dt", "linxia", + "你把那个通知划掉了。也许不是现在。", + "p0"), + + # 消息16 · 结局触发点(条件分支) + ("linxia/linxia_msg16_narrate_a", "linxia", + "凌晨两点。妈妈发来一段很长的语音,她说她一直有些话想说给你听。", + "p2"), + ("linxia/linxia_msg16_narrate_b", "linxia", + "凌晨两点。房间很安静,窗外偶尔有车声。今晚就这样了。", + "p2"), + ("linxia/linxia_msg16_tap_a", "linxia", + "妈妈说了很多——关于你小时候,关于她一直担心的事,关于她说不出口但一直想说的话。" + "你把手机贴在耳边,听完了。", + "p2"), + ("linxia/linxia_msg16_tap_b", "linxia", + "你就这样坐着,今晚的声音慢慢变少了。", + "p2"), + ("linxia/linxia_msg16_dt", "linxia", + "你把手机放到床头,闭上眼睛。今晚就这样了。", + "p0"), + + # 结局 + ("linxia/linxia_ending_return", "linxia", + "《回归》。阿哲来了,或者你们说好了见面。不管那个门开不开,今晚你做了一个决定。", + "p1"), + ("linxia/linxia_ending_link", "linxia", + "《连接》。今晚你和妈妈说了话,不止一次。那种联系很细,但是在。也许明天你会想打电话回去。", + "p1"), + ("linxia/linxia_ending_past", "linxia", + "《旧路》。周南在深夜找到了你,你们聊了很久。那条从高中就断掉的线,今晚接上了一点点。", + "p1"), + ("linxia/linxia_ending_self", "linxia", + "《自省》。今晚你听了三年前自己的声音。她说辛苦了,你接受了这句话。这不算小事。", + "p1"), + ("linxia/linxia_ending_island", "linxia", + "《孤岛》。今晚大部分的消息,你都没有回应。你一个人度过了这个夜晚。这也是一种选择,你心里清楚。", + "p1"), + + # ══════════════════════════════════════════════════════════ + # 《一念》 — 内心独白 + # ══════════════════════════════════════════════════════════ + + # 开场 + ("yinian/yinian_intro", "yinian", + "你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。" + "七个声音在等着和你说话。单击,让它说完。双击,让它走。", + "p0"), + + # 7个声音 + ("yinian/yinian_v01_narrate", "yinian", + "第一个声音来了。是三年前的你。那时候你刚做了一个选择,你觉得那是对的。" + "那时候的你说:我们当时想要的,你还记得吗?", + "p0"), + ("yinian/yinian_v01_tap", "yinian", + "你让三年前的自己说完了。她说,你当时很想要那件事。你知道那是真的。", + "p0"), + ("yinian/yinian_v01_dt", "yinian", + "三年前的声音淡去了。你选择了不看它。", + "p0"), + + ("yinian/yinian_v02_narrate", "yinian", + "第二个声音。是一个你很久没联系的朋友,她留了条语音。" + "她说:你最近怎么样,我有点担心你。很久了,你一直没回。", + "p0"), + ("yinian/yinian_v02_tap", "yinian", + "你把这份担心收进来了。她还在关心你,这件事是真实的。", + "p0"), + ("yinian/yinian_v02_dt", "yinian", + "留言消失了。你没有听完。", + "p0"), + + ("yinian/yinian_v03_narrate", "yinian", + "第三个声音。是关于钱的恐惧。它说:你知道你现在能撑多久吗?" + "如果这件事不成,你怎么办?它问得很具体,很冷静。", + "p0"), + ("yinian/yinian_v03_tap", "yinian", + "你承认了:这份恐惧是真实的。你带着它,而不是假装它不存在。", + "p0"), + ("yinian/yinian_v03_dt", "yinian", + "恐惧的声音淡下去了。你知道它还在,但今晚先不看。", + "p0"), + + ("yinian/yinian_v04_narrate", "yinian", + "第四个声音。是一种渴望——想要被人理解,想要有人在身边,想要不那么孤独。它来得轻,但是很深。", + "p0"), + ("yinian/yinian_v04_tap", "yinian", + "你承认了这份渴望。它是真实的,不是软弱。", + "p0"), + ("yinian/yinian_v04_dt", "yinian", + "渴望漂走了。你告诉自己,一个人也没什么。", + "p0"), + + ("yinian/yinian_v05_narrate", "yinian", + "第五个声音。是一个你一直没有说出口的道歉。你知道是对谁的,你知道说的是什么。" + "这个声音只是在等你。", + "p0"), + ("yinian/yinian_v05_tap", "yinian", + "你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。", + "p0"), + ("yinian/yinian_v05_dt", "yinian", + "道歉的声音还在那里。你没有说。它等着。", + "p0"), + + ("yinian/yinian_v06_narrate", "yinian", + "第六个声音。是今晚那个决定本身。它把两种可能都摆在你面前,不说话,只是等着你看清楚。", + "p0"), + ("yinian/yinian_v06_tap", "yinian", + "你看了。两条路都有代价,你知道了。这不让选择变得更容易,但让它变得更真实。", + "p0"), + ("yinian/yinian_v06_dt", "yinian", + "你把眼睛闭上了。还没准备好看那两条路。", + "p0"), + + # 第七声音(条件分支) + ("yinian/yinian_v07_narrate_a", "yinian", + "第七个声音,是现在的你。她说:你今晚接住了很多,你没有回避它们。这需要力气。", + "p2"), + ("yinian/yinian_v07_narrate_b", "yinian", + "第七个声音,是现在的你。她沉默了很久,然后说:你把它们都推走了。" + "也许你需要的不是答案,是喘息。", + "p2"), + ("yinian/yinian_v07_narrate_c", "yinian", + "第七个声音,是现在的你。她说:你把恐惧留下了,但把渴望推走了。你知道这意味着什么吗?", + "p2"), + ("yinian/yinian_v07_narrate_d", "yinian", + "第七个声音,是现在的你。她说:你接住了一些,放走了一些。这就是你今晚的状态。", + "p0"), + ("yinian/yinian_v07_tap", "yinian", + "你听完了现在的自己说话。她说:不管你今晚做什么决定,你都已经在这里待过了。这算数。", + "p0"), + ("yinian/yinian_v07_dt", "yinian", + "你没有听完。你觉得你已经知道了你需要知道的。", + "p0"), + + # 结局 + ("yinian/yinian_ending_accept", "yinian", + "《接纳》。你今晚接住了很多,你没有选择只是轻装。也许你的决定,就是带着这些往前走。", + "p1"), + ("yinian/yinian_ending_release", "yinian", + "《出发》。你今晚把大部分都放走了。你想要的也许是轻。轻装,也是一种勇气。", + "p1"), + ("yinian/yinian_ending_stuck", "yinian", + "《困住》。你接受了恐惧,但推走了渴望。你知道风险,但忘了你想要的是什么。" + "这个决定,也许需要再等一等。", + "p1"), + ("yinian/yinian_ending_reconcile", "yinian", + "《和解》。你说出了那声道歉。不管今晚的决定是什么,你和自己和解了一点点。这很重要。", + "p1"), + ("yinian/yinian_ending_between", "yinian", + "《悬在中间》。你接住了一些,放走了一些。你还悬在两个答案之间。" + "也许这就是你今晚真实的状态。", + "p1"), + + # ══════════════════════════════════════════════════════════ + # 《守夜》 — 对讲机提示 + # ══════════════════════════════════════════════════════════ + + # 开场 + ("shouye/shouye_intro", "shouye", + "凌晨零点。你是这栋楼的夜班保安,独自坐在一楼监控室。" + "你不能离开座位,但你有一部对讲机和一部电话。今晚五点交班。" + "单击,去查那个声音。双击,按兵不动。", + "p0"), + + # 8个事件 + ("shouye/shouye_s01_narrate", "shouye", + "凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。", + "p0"), + ("shouye/shouye_s01_tap", "shouye", + "你拨了地下室的内线。没人接。你下去看了一眼,是一根管子在漏水,用旧布堵住了。" + "回到监控室,零点二十三分。", + "p0"), + ("shouye/shouye_s01_dt", "shouye", + "你听了一会儿。应该不碍事。你把注意力放回到监控屏幕上。", + "p0"), + + ("shouye/shouye_s02_narrate", "shouye", + "零点四十七分。四楼传来争吵声,一男一女,断断续续,能听出来语气很激动,但听不清说什么。", + "p0"), + ("shouye/shouye_s02_tap", "shouye", + "你拨了四楼的号码。男的接了,说没事,就是聊天,让你别担心。" + "声音还是有点紧,但他们关掉了。你记下来了:四楼,零点五十二分,双方均在场。", + "p0"), + ("shouye/shouye_s02_dt", "shouye", + "家务事,你管不着。你转过身去继续盯着屏幕。争吵后来停了,你不知道是什么时候。", + "p0"), + + ("shouye/shouye_s03_narrate", "shouye", + "凌晨一点十七分。三楼有脚步声,很轻,走走停停。" + "监控记录:三楼只有七零二亮着灯,住户是老陈,八十岁,独居。", + "p0"), + ("shouye/shouye_s03_tap", "shouye", + "电话通了,老陈说他在找眼镜,眼镜掉床底下了。你说没事我就是例行确认一下。" + "他说谢谢你小伙子,然后你们聊了几句,关于他年轻时候也做过工程。你挂掉电话,一点二十八分。", + "p0"), + ("shouye/shouye_s03_dt", "shouye", + "老人家睡不好,很正常。你记了一笔,继续监控。脚步声后来停了。", + "p0"), + + ("shouye/shouye_s04_narrate", "shouye", + "凌晨两点零三分。大门门铃响了,不是访客系统,是有人在外面按了手动铃," + "按了两下,然后停了。没有人应答。监控画面:门口没有人了。", + "p0"), + ("shouye/shouye_s04_tap", "shouye", + "你走到门口,什么都没有,只有一个快递包裹靠在墙边,大概是外卖或者什么。" + "也许是送错了。你把包裹推到角落,拍了张照,两点一刻回到座位。", + "p0"), + ("shouye/shouye_s04_dt", "shouye", + "无应答,无入侵,记录在案,按流程忽略。你回到屏幕前。", + "p0"), + + # s05条件分支 + ("shouye/shouye_s05_narrate_a", "shouye", + "凌晨两点五十五分。你不知道从什么时候开始,眼皮变得很重……你睡着了一会儿。" + "醒来的时候,你看到监控里,四楼门口放着一个箱子,不知道是谁的,不知道什么时候放的。", + "p2"), + ("shouye/shouye_s05_narrate_b", "shouye", + "凌晨两点五十五分。你不知道从什么时候开始,眼皮变得很重……你睡着了一会儿。" + "醒来的时候,监控里一切正常。你记录:三点十二分,短暂失守。", + "p2"), + + ("shouye/shouye_s06_narrate", "shouye", + "凌晨三点半。你的私人手机响了。是家里的人打来的。这么晚打来——可能是什么事,也可能只是弄错时间了。", + "p0"), + ("shouye/shouye_s06_tap", "shouye", + "是你妈,她说睡不着,看见你今天发的朋友圈,就想打个电话。" + "你们聊了十分钟,说了些不重要的事。挂掉之后,监控室比之前暖了一点点。", + "p0"), + ("shouye/shouye_s06_dt", "shouye", + "这么晚不方便。你把手机调成静音,准备待会儿发条消息。你没有发。", + "p0"), + + ("shouye/shouye_s07_narrate", "shouye", + "凌晨四点十二分。屋顶传来动静,断断续续,不像是机械设备,更像是有什么东西在走动。", + "p0"), + ("shouye/shouye_s07_tap", "shouye", + "你拿着手电上了顶层,推开屋顶的铁门,风吹过来,你看见了天边刚刚开始发白的颜色——" + "还不是黎明,但是快了。然后你看见了一只流浪猫,坐在水箱上看着你。" + "你们看了对方一会儿,然后你下来了。你是今晚唯一上了屋顶的人。", + "p0"), + ("shouye/shouye_s07_dt", "shouye", + "你记了一笔,继续监控。动静后来停了。", + "p0"), + + ("shouye/shouye_s08_narrate", "shouye", + "五点整。接班的人进来了,你们交接了几句,递了记录本,你背上包走出去。外面已经有一点点亮了。", + "p0"), + + # 结局 + ("shouye/shouye_ending_dawn", "shouye", + "《你看到了什么》。今晚你一个人上了屋顶,你看到了那只猫,你看到了天边开始变色。" + "只有你看到了。这算不上什么,但只有你知道。", + "p1"), + ("shouye/shouye_ending_solid", "shouye", + "《平凡守夜》。你今晚查了应该查的,接了应该接的电话。没有大事,没有遗漏。" + "这栋楼平安了一夜,有一部分是因为你在。", + "p1"), + ("shouye/shouye_ending_detached", "shouye", + "《这不是我的事》。你今晚保持了距离。声音来了,你记录,你不动。" + "你守住了一夜,但不知道楼里发生了什么。这也是一种守夜的方式。", + "p1"), + ("shouye/shouye_ending_oversight", "shouye", + "《疏忽》。你今晚漏掉了四楼的事。那个女人在凌晨两点多拎着箱子离开了,你没有注意到。" + "也许和你没有关系。也许有。你不知道。", + "p1"), + ("shouye/shouye_ending_ordinary", "shouye", + "《普通的夜班》。你做了你能做的,漏掉了一些,照顾到了一些。和所有普通的夜班一样。", + "p1"), + + # ══════════════════════════════════════════════════════════ + # 《候诊》 — 时间之声 + # ══════════════════════════════════════════════════════════ + + # 开场 + ("houzhen/houzhen_intro", "houzhen", + "下午一点半,你坐在医院候诊室,等一个检查结果。叫号机已经叫到了八十四号。" + "你是九十四号。单击,把注意力放到外面。双击,退回到自己里面。", + "p0"), + + # 9个时间切片 + ("houzhen/houzhen_h01_narrate", "houzhen", + "叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。", + "p0"), + + ("houzhen/houzhen_h02_narrate", "houzhen", + "你左边坐着一个老人。他在看窗外,窗外是停车场,没有什么特别的。但他看得很专注。", + "p0"), + ("houzhen/houzhen_h02_tap", "houzhen", + "你看了他一会儿。他感觉到了,朝你点了点头。你也点了点头。没有说话,但有什么东西发生了。", + "p0"), + ("houzhen/houzhen_h02_dt", "houzhen", + "你低下头,看手机。屏幕亮着,什么都有,但你没有真的看进去什么。", + "p0"), + + ("houzhen/houzhen_h03_wait", "houzhen", + "叫号机叫到了八十八号。", + "p0"), + ("houzhen/houzhen_h03_narrate", "houzhen", + "你手机上有一条两天前的消息,你当时看到了,但没有回。那个人说:最近怎么样。", + "p0"), + ("houzhen/houzhen_h03_tap", "houzhen", + "你打开来,打了几个字。发出去了。不知道他会不会在意这么晚才回。", + "p0"), + ("houzhen/houzhen_h03_dt", "houzhen", + "你把手机翻过去,屏幕朝下放在大腿上。等等再说。", + "p0"), + + ("houzhen/houzhen_h04_wait", "houzhen", + "护士从走廊那头走过来,叫了一个名字,不是你。", + "p0"), + ("houzhen/houzhen_h04_narrate", "houzhen", + "对面有个小孩在哭,大约三四岁,蹲在椅子旁边哭。旁边的大人在轻声说什么,但他还是哭。", + "p0"), + ("houzhen/houzhen_h04_tap", "houzhen", + "你看着那个孩子。他哭了一会儿就停了,大概是哭够了。你们没有交流,但你陪着他哭完了。", + "p0"), + ("houzhen/houzhen_h04_dt", "houzhen", + "你把视线移开了。每个人都有自己的事,你也有你的。", + "p0"), + + ("houzhen/houzhen_h05_wait", "houzhen", + "叫号机叫到了九十号。还有四个人。", + "p0"), + ("houzhen/houzhen_h05_narrate", "houzhen", + "窗外的光线变了。下午的阳光斜进来,在地板上画了一块长方形的亮。你注意到了。", + "p0"), + ("houzhen/houzhen_h05_tap", "houzhen", + "你盯着那块光看了一会儿。它在慢慢移动,非常慢,但是在动。时间在过。", + "p0"), + ("houzhen/houzhen_h05_dt", "houzhen", + "你闭上眼睛,想了想别的事。", + "p0"), + + ("houzhen/houzhen_h06_wait", "houzhen", + "叫号机叫到了九十一号。", + "p0"), + ("houzhen/houzhen_h06_narrate", "houzhen", + "你的手机响了。是一个你不常打电话的人——是你家里的人。也许只是普通的问候。", + "p0"), + ("houzhen/houzhen_h06_tap", "houzhen", + "你接了。对方说:你在哪啊,吃了吗。你说在外面,没事。就这样。挂掉之后候诊室里还是那么安静。", + "p0"), + ("houzhen/houzhen_h06_dt", "houzhen", + "你看着屏幕亮了又暗。现在不是时候。等会儿再说。", + "p0"), + + ("houzhen/houzhen_h07_wait", "houzhen", + "叫号机叫到了九十二号。", + "p0"), + ("houzhen/houzhen_h07_narrate", "houzhen", + "你右边的人一直在看同一页的杂志,翻来翻去都是那页。你不确定她在看,还是只是拿着。", + "p0"), + ("houzhen/houzhen_h07_tap", "houzhen", + "你朝她看了一眼。你们的眼神碰了一下,然后都移开了。没有什么,就是两个陌生人在等待。", + "p0"), + ("houzhen/houzhen_h07_dt", "houzhen", + "你没有看。你在想等会儿检查结果会说什么。", + "p0"), + + ("houzhen/houzhen_h08_narrate", "houzhen", + "九十三号。就差一个了。", + "p0"), + + ("houzhen/houzhen_h09_narrate", "houzhen", + "九十四号。是你。你站起来,整理了一下衣服,走向那扇门。", + "p0"), + ("houzhen/houzhen_h09_tap", "houzhen", + "你推开门走进去了。门关上了。等待结束了。", + "p0"), + ("houzhen/houzhen_h09_dt", "houzhen", + "你停了一秒,深呼吸,然后走进去了。门关上了。", + "p0"), + + # ══════════════════════════════════════════════════════════ + # 《遗声》 — 手机系统语音 + # ══════════════════════════════════════════════════════════ + + # 开场 + ("yisheng/yisheng_intro", "yisheng", + "你收到了一部手机。手机的主人去世了,一个月前。他留下了很多录音,按时间排列。" + "你不认识他。或者你以为你不认识他。单击,听这条录音。双击,跳过,永远不知道这条说了什么。", + "p0"), + + # 15条录音的元数据播报 + ("yisheng/yisheng_r01_narrate", "yisheng", + "第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。", + "p0"), + ("yisheng/yisheng_r02_narrate", "yisheng", + "第二条录音。三月十二日,下午。时长:三十八秒。文件名:发工资了。", + "p0"), + ("yisheng/yisheng_r03_narrate", "yisheng", + "第三条录音。三月十八日,晚上。时长:一分零七秒。文件名:说说工作的事。", + "p0"), + ("yisheng/yisheng_r04_narrate", "yisheng", + "第四条录音。四月二日。时长:四十九秒。文件名:吵了一架。", + "p0"), + ("yisheng/yisheng_r05_narrate", "yisheng", + "第五条录音。四月十五日,深夜。时长:二十六秒。文件名:电影。", + "p0"), + ("yisheng/yisheng_r06_narrate", "yisheng", + "第六条录音。五月三日。时长:一分二十秒。文件名:小林的事。", + "p0"), + ("yisheng/yisheng_r07_narrate", "yisheng", + "第七条录音。五月二十日。时长:三十三秒。文件名:小林说的。", + "p0"), + ("yisheng/yisheng_r08_narrate", "yisheng", + "第八条录音。六月八日,凌晨。时长:两分十四秒。文件名:结束了。", + "p0"), + ("yisheng/yisheng_r09_narrate", "yisheng", + "第九条录音。六月二十日。时长:一分五十秒。文件名:在反思。", + "p0"), + ("yisheng/yisheng_r10_narrate", "yisheng", + "第十条录音。七月四日。时长:四十二秒。文件名:有人问起你。", + "p0"), + ("yisheng/yisheng_r11_narrate", "yisheng", + "第十一条录音。七月十一日。时长:一分零八秒。文件名:我查了一下。", + "p0"), + ("yisheng/yisheng_r12_narrate", "yisheng", + "第十二条录音。八月二日。时长:五十七秒。文件名:给不认识我的人(草稿)。", + "p0"), + ("yisheng/yisheng_r13_narrate", "yisheng", + "第十三条录音。九月一日。时长:两分三十八秒。文件名:我有一件事想说。", + "p0"), + ("yisheng/yisheng_r14_narrate", "yisheng", + "第十四条录音。十月十五日,录制时间是他去世前三天。时长:三分二十秒。文件名:最后一条,我想讲完整。", + "p0"), + ("yisheng/yisheng_r15_narrate", "yisheng", + "还有一条录音,没有标日期。文件名:给不认识我的人(正式版)。这条需要你听了很多条才能解锁。你解锁了。", + "p0"), + + # 听完后的旁白(转述录音内容) + ("yisheng/yisheng_r01_tap", "yisheng", + "他说:今天买了一袋橙子,是脐橙,比上次的甜很多。建议大家有机会可以试试。就这些。录音结束。", + "p0"), + ("yisheng/yisheng_r02_tap", "yisheng", + "他说:今天发工资了。然后列了他这个月的支出清单,很详细——" + "房租、电费、手机月供、还有给他妈买了一件外套。他说最后剩了不多,但够。录音结束。", + "p0"), + ("yisheng/yisheng_r03_tap", "yisheng", + "他说他们组里来了个新同事,总是在会议上抢着发言,说了很多他本来准备说的话。" + "他说他当时没说话,他不确定那是不是对的选择。" + "他在录音里问自己:我是懦弱了,还是留有余地?他没有答案。录音结束。", + "p0"), + ("yisheng/yisheng_r04_tap", "yisheng", + "他说他今天和那个新同事正面冲突了,是在走廊,两个人都没控制住。" + "他说他说了一句话,事后觉得说得太重了。" + "他在录音里把那句话又说了一遍,想听听是不是真的太重。" + "录音里他沉默了一会儿,然后说:是的,是太重了。录音结束。", + "p0"), + ("yisheng/yisheng_r05_tap", "yisheng", + "他说他去看了一部很老的电影,黑白的,在一个小影院,观众只有六个人。" + "他说他中途哭了,但他不确定是因为电影,还是因为电影院里那种空荡荡的感觉。录音结束。", + "p0"), + ("yisheng/yisheng_r06_tap", "yisheng", + "他开始讲一个叫小林的朋友。他说小林是他认识最久的人,从小一起长大的," + "后来各自去了不同的城市,联系越来越少。" + "他说他最近一直在想,那种联系的稀薄,是谁的责任?还是说这就是长大的代价?录音结束。", + "p0"), + ("yisheng/yisheng_r07_tap", "yisheng", + "他说小林打电话来了。小林在电话里说了一句话,让他很难受。" + "小林说:感觉你现在不太一样了,变得……不知道,挺难接近的。" + "他说他当时说了没什么,你想多了。" + "他说他那时候应该说:我只是最近状态不好。但他没说。录音结束。", + "p0"), + ("yisheng/yisheng_r08_tap", "yisheng", + "他说一段感情结束了,他没有细说是谁,说了有多久,只说:结束了,是我提的," + "我觉得这是对的,但今晚我睡不着。" + "他说他在厨房坐着,泡了杯茶,但茶凉了也没喝。" + "他说:我只是想把今晚的感觉记下来,不想让它消失。录音结束。", + "p0"), + ("yisheng/yisheng_r09_tap", "yisheng", + "他说他一直在想那段感情里他做错了什么。他列了很多条,每一条都很具体。" + "然后他说:但是我说不清,这些错是因为我是这样的人," + "还是因为那时候我在经历别的事情,所以才这样。" + "他说:这个问题很重要,但我没有答案。录音结束。", + "p0"), + ("yisheng/yisheng_r10_tap", "yisheng", + "他说:今天有人跟我提到了你的名字。我以为我早就忘了,但我发现我还记得很多细节。" + "他没有解释「你」是谁。" + "他说:我觉得我应该把这件事录下来,因为我发现我不想忘记。录音结束。", + "p0"), + ("yisheng/yisheng_r11_tap", "yisheng", + "他说:我查了你现在在哪里,在做什么。不是恶意,就是想知道。" + "他说他找到了一些信息,知道了你现在的大致状态。" + "他说:你好像过得还不错,我很高兴,也有一点……他停了,然后说:没什么。录音结束。", + "p0"), + ("yisheng/yisheng_r12_tap", "yisheng", + "他说:如果你听到这条,你肯定不认识我。我在想,把这些录音留给一个陌生人,有什么意义。" + "然后他说:也许是因为我希望有人知道我活过,而不是认识我的人," + "而是真正陌生的人——你不需要对我有感情,你只是听了。录音结束。", + "p0"), + ("yisheng/yisheng_r13_tap", "yisheng", + "他深吸一口气,说:有一件事我从没对任何人说过。" + "他停了很久,然后说:我有时候觉得活着的意义我不太找得到," + "不是说我要做什么,只是我不知道我在这里有什么用。" + "他说:我知道这句话很沉,但我必须把它说出来,因为放在心里太久了。" + "然后他说:我今天状态还可以,就是今天有这个力气把它说出来。录音结束。", + "p0"), + ("yisheng/yisheng_r14_tap", "yisheng", + "他说:我知道这是最后一条了。我不是悲观,我只是知道。" + "他说:我想对听这些录音的人说,我不后悔我活过的大多数时候。" + "橙子是甜的,老电影是值得哭的,小林的声音我还记得," + "那个人,不管怎样,我不后悔爱过。" + "他说:如果你听了很多条,谢谢你。你是陌生人,但你陪着我说完了。录音结束。", + "p0"), + ("yisheng/yisheng_r15_tap", "yisheng", + "他说:你好。你不认识我,我也不认识你。" + "但你坐在那里,听完了我这些年说的话。" + "我不知道你是谁,我不知道你们那边现在是白天还是夜晚。" + "我只是想说:谢谢你愿意听一个陌生人说话。这很重要,比你知道的还要重要。" + "录音结束。这是最后一条了。", + "p0"), + + # 跳过时的旁白 + ("yisheng/yisheng_skip_generic", "yisheng", + "你跳过了这条。你永远不会知道这条说了什么了。", + "p0"), + ("yisheng/yisheng_skip_short", "yisheng", + "你跳过了。", + "p0"), + ("yisheng/yisheng_skip_last", "yisheng", + "你跳过了第十四条,也就是最后一条。手机里没有更多录音了。", + "p0"), + ("yisheng/yisheng_skip_r15", "yisheng", + "你连最后这条也跳过了。", + "p0"), + + # 结局 + ("yisheng/yisheng_ending_known", "yisheng", + "《我认识你》。你听完了他大部分的录音,你听到了那条隐藏的最后一条。" + "你不认识他,但你陪他把该说的都说完了。这已经足够了。", + "p1"), + ("yisheng/yisheng_ending_stranger", "yisheng", + "《陌生人》。你跳过了所有关于他和你之间联系的录音。" + "他对你来说,还是一个完全的陌生人。也许这样也好。", + "p1"), + ("yisheng/yisheng_ending_passing", "yisheng", + "《路过》。你路过了他的大部分录音,只停留了几条。你知道他存在过,但你不知道他是谁。", + "p1"), + ("yisheng/yisheng_ending_outline", "yisheng", + "《轮廓》。你听了他生活的碎片,知道了一个大致的形状。" + "你不知道他所有的事,但你知道了一个人活着的样子。", + "p1"), + ("yisheng/yisheng_ending_brief", "yisheng", + "《简短的相遇》。你听了几条,然后停下了。" + "也许你听到了你需要听的,也许你还没准备好听更多。这也是一种结果。", + "p1"), + + # ══════════════════════════════════════════════════════════ + # 《浮》 — 梦里的声音 + # ══════════════════════════════════════════════════════════ + + # 开场 + ("fu/fu_intro", "fu", + "你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。单击,把手伸过去。双击,看着它走。", + "p0"), + + # 12个漂流物 + ("fu/fu_f01_narrate", "fu", + "第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。", + "p0"), + ("fu/fu_f01_tap", "fu", + "它停在你手心里,有点暖。", + "p0"), + ("fu/fu_f01_dt", "fu", + "你看着它慢慢飘走了。", + "p0"), + + ("fu/fu_f02_narrate", "fu", + "第二个。某人的笑声。你认识这个笑,但你说不清是谁的。", + "p0"), + ("fu/fu_f02_tap", "fu", + "笑声停在你耳边,还在回响。", + "p0"), + ("fu/fu_f02_dt", "fu", + "笑声远了。", + "p0"), + + ("fu/fu_f03_narrate", "fu", + "第三个。一种说不清楚的遗憾。它没有形状,但是很重。", + "p0"), + ("fu/fu_f03_tap", "fu", + "你把遗憾抱在怀里了。它很重,但你没有放开。", + "p0"), + ("fu/fu_f03_dt", "fu", + "遗憾漂过去了。你呼了口气。", + "p0"), + + ("fu/fu_f04_narrate", "fu", + "第四个。很久没有过的安全感。轻轻的,像一床旧被子的气味。", + "p0"), + ("fu/fu_f04_tap", "fu", + "你把它裹在身上了。", + "p0"), + ("fu/fu_f04_dt", "fu", + "安全感飘走了。你看着它走。", + "p0"), + + ("fu/fu_f05_narrate", "fu", + "第五个。想哭但没哭出来的那种感觉。像喉咙里卡着什么。", + "p0"), + ("fu/fu_f05_tap", "fu", + "你承认了。那个东西松动了一点点。", + "p0"), + ("fu/fu_f05_dt", "fu", + "它漂走了。喉咙还是有点紧。", + "p0"), + + ("fu/fu_f06_narrate", "fu", + "第六个。一首旋律,只有开头几个音,后面的你想不起来了。", + "p0"), + ("fu/fu_f06_tap", "fu", + "你在心里哼了那几个音。后面的还是想不起来,但这几个音够了。", + "p0"), + ("fu/fu_f06_dt", "fu", + "旋律消失了。", + "p0"), + + ("fu/fu_f07_narrate", "fu", + "第七个。被需要的感觉。有人曾对你说过你很重要,你还记得那句话。", + "p0"), + ("fu/fu_f07_tap", "fu", + "你把那句话放进口袋里了。", + "p0"), + ("fu/fu_f07_dt", "fu", + "那句话漂走了。", + "p0"), + + ("fu/fu_f08_narrate", "fu", + "第八个。一个人的时候的自在。没人看着,没人要你做什么,只是你自己。", + "p0"), + ("fu/fu_f08_tap", "fu", + "你把这份自在收进来了。", + "p0"), + ("fu/fu_f08_dt", "fu", + "它漂走了。也许你现在不需要。", + "p0"), + + ("fu/fu_f09_narrate", "fu", + "第九个。害怕。不知道怕什么,就是有点怕。", + "p0"), + ("fu/fu_f09_tap", "fu", + "你承认了:我有点害怕。说出来之后,它小了一点。", + "p0"), + ("fu/fu_f09_dt", "fu", + "你没有看它,害怕漂走了。也许。", + "p0"), + + ("fu/fu_f10_narrate", "fu", + "第十个。好奇心。对某件事、某个人、某个地方,你还有好奇。", + "p0"), + ("fu/fu_f10_tap", "fu", + "好奇心跳进你手里了,很活跃。", + "p0"), + ("fu/fu_f10_dt", "fu", + "好奇心漂走了,也许它去找别人了。", + "p0"), + + ("fu/fu_f11_narrate", "fu", + "第十一个。疲惫。真实的、身体里的疲惫,它已经跟了你很久了。", + "p0"), + ("fu/fu_f11_tap", "fu", + "你说:我累了。疲惫点了点头,说我知道。", + "p0"), + ("fu/fu_f11_dt", "fu", + "疲惫漂走了。你感觉稍微轻了一点点。", + "p0"), + + ("fu/fu_f12_narrate", "fu", + "最后一个。它没有名字。是你漂浮在这里时特有的某种感觉,说不清,但它在。", + "p0"), + ("fu/fu_f12_tap", "fu", + "你把它揣在怀里了,连同它的没有名字。", + "p0"), + ("fu/fu_f12_dt", "fu", + "你看着它留在了这片水里,这片梦里。", + "p0"), + + # 动态结局分片(p3,用于拼接) + ("fu/fu_ending_open", "fu", "你漂回来了。", "p3"), + ("fu/fu_ending_kept_prefix", "fu", "你带走了:", "p3"), + ("fu/fu_ending_released_prefix", "fu", "你放走了:", "p3"), + ("fu/fu_ending_close", "fu", "这就是今天的你。", "p3"), + ("fu/fu_item_wind", "fu", "夏天的风", "p3"), + ("fu/fu_item_laugh", "fu", "笑声", "p3"), + ("fu/fu_item_regret", "fu", "遗憾", "p3"), + ("fu/fu_item_safe", "fu", "安全感", "p3"), + ("fu/fu_item_tears", "fu", "没哭出来的眼泪", "p3"), + ("fu/fu_item_melody", "fu", "那段旋律", "p3"), + ("fu/fu_item_needed", "fu", "被需要的感觉", "p3"), + ("fu/fu_item_alone", "fu", "独处的自在", "p3"), + ("fu/fu_item_fear", "fu", "害怕", "p3"), + ("fu/fu_item_curious", "fu", "好奇心", "p3"), + ("fu/fu_item_tired", "fu", "疲惫", "p3"), + ("fu/fu_item_nameless","fu", "无名之物", "p3"), +] + + +# ────────────────────────────────────────────────────────────── +# 工具函数 +# ────────────────────────────────────────────────────────────── + +def find_ref_audio(source_dir, narrator_key): + narrator_dir = os.path.join(source_dir, "narrator", NARRATORS[narrator_key]["dir"]) + if not os.path.isdir(narrator_dir): + return None + for ext in ("*.wav", "*.mp3", "*.flac"): + files = sorted(glob.glob(os.path.join(narrator_dir, ext))) + if files: + return files[0] + return None + + +def convert_to_wav(src_path, dst_path): + cmd = ["ffmpeg", "-y", "-i", src_path, + "-ar", "16000", "-ac", "1", "-acodec", "pcm_s16le", dst_path] + subprocess.run(cmd, check=True, capture_output=True) + + +def wav_to_base64(wav_path): + with open(wav_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + + +def register_voice(audio_path): + b64 = wav_to_base64(audio_path) + r = requests.post(f"{BASE_URL}/v1/voices", json={"wav_base64": b64}, timeout=60) + r.raise_for_status() + return r.json()["voice_id"] + + +def synthesize_voxcpm(voice_id, text, cfg_value=2.0, inference_timesteps=10): + r = requests.post( + f"{BASE_URL}/v1/speech", + json={"text": text, "voice_id": voice_id, + "cfg_value": cfg_value, "inference_timesteps": inference_timesteps}, + timeout=120, + ) + r.raise_for_status() + return r.content + + +def apply_speed_and_convert_mp3(wav_path, speed, out_mp3): + """调速 + 转 MP3(44100Hz mono 128kbps),添加首尾静音""" + filter_chain = f"aresample=44100,atempo={speed}" if abs(speed - 1.0) >= 0.01 else "aresample=44100" + # 添加 100ms 头部静音 + 200ms 尾部静音 + filter_chain += ",adelay=100|100,apad=pad_dur=0.2" + cmd = [ + "ffmpeg", "-y", "-i", wav_path, + "-filter:a", filter_chain, + "-ac", "1", + "-b:a", "128k", + out_mp3, + ] + subprocess.run(cmd, check=True, capture_output=True) + + +def check_server(): + try: + r = requests.get(f"{BASE_URL}/health", timeout=5) + return r.status_code == 200 + except Exception: + return False + + +# ────────────────────────────────────────────────────────────── +# 主流程 +# ────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="盲游 · 旁白 TTS 批量生产 — VoxCPM2 版") + parser.add_argument("--source", choices=["voice", "voice_mp3"], default="voice_mp3", + help="音色参考目录 (默认: voice_mp3)") + parser.add_argument("--game", help="只生产指定游戏 (linxia/yinian/shouye/houzhen/yisheng/fu)") + parser.add_argument("--priority", choices=["p0", "p1", "p2", "p3", "all"], default="all", + help="只生产指定优先级 (p0=核心 p1=结局 p2=条件变体 p3=动态分片)") + parser.add_argument("--test", action="store_true", help="只合成第一条") + parser.add_argument("--dry-run", action="store_true", help="只打印任务列表") + parser.add_argument("--cfg", type=float, default=2.0, help="VoxCPM cfg_value (默认: 2.0)") + parser.add_argument("--steps", type=int, default=10, help="VoxCPM inference_timesteps (默认: 10)") + args = parser.parse_args() + + source_dir = os.path.join(PROJECT_DIR, args.source) + + # 过滤 + lines = LINES + if args.game: + lines = [(f, n, t, p) for f, n, t, p in lines if n == args.game] + if args.priority != "all": + lines = [(f, n, t, p) for f, n, t, p in lines if p == args.priority] + if args.test: + lines = lines[:1] + + print(f"\n{'=' * 60}") + print(f" 盲游旁白 TTS 批量合成 — VoxCPM2") + print(f" 音色源: {args.source}/narrator/") + print(f" 输出: {OUT_BASE}/{{game_id}}/") + print(f" 共 {len(lines)} 条") + print(f"{'=' * 60}") + + if args.dry_run: + game_counts = {} + for f, n, t, p in lines: + game_id = f.split("/")[0] + game_counts[game_id] = game_counts.get(game_id, 0) + 1 + ref = find_ref_audio(source_dir, n) + ref_status = "✓" if ref else "✗缺参考" + print(f" [{ref_status}][{p}] {f}.mp3 speed={NARRATORS[n]['speed']} {t[:40]}...") + print(f"\n 统计:") + for g, c in sorted(game_counts.items()): + print(f" {g}: {c} 条") + return + + # 检查服务 + if not check_server(): + print(f"\n[错误] VoxCPM 服务未就绪: {BASE_URL}/health") + print(f"请先启动: bash /home/xsl/AutoVideo/start-voxcpm.sh") + sys.exit(1) + print(f" 服务就绪: {BASE_URL}\n") + + # 预处理:注册音色 + voice_ids = {} + tmp_wav_dir = tempfile.mkdtemp(prefix="narrator_ref_") + + needed_narrators = set(n for _, n, _, _ in lines) + for nar_key in sorted(needed_narrators): + ref_path = find_ref_audio(source_dir, nar_key) + if ref_path is None: + print(f" [跳过] {nar_key}: 未找到参考音频 ({source_dir}/narrator/{nar_key}/)") + print(f" 请在该目录放入参考录音(wav/mp3/flac),音色来源:{NARRATORS[nar_key]['note']}") + continue + + if ref_path.lower().endswith(".wav"): + wav_path = ref_path + else: + wav_path = os.path.join(tmp_wav_dir, f"{nar_key}_ref.wav") + print(f" 转换参考音频: {os.path.basename(ref_path)} → WAV...", end=" ", flush=True) + convert_to_wav(ref_path, wav_path) + print("ok") + + print(f" 注册音色: {nar_key} ({NARRATORS[nar_key]['note']})...", end=" ", flush=True) + try: + vid = register_voice(wav_path) + voice_ids[nar_key] = vid + print(f"ok voice_id={vid}") + except Exception as e: + print(f"失败: {e}") + + print() + + # 合成 + ok_count, skip_count, fail_count = 0, 0, 0 + + for i, (filename, nar_key, text, priority) in enumerate(lines, 1): + game_id = filename.split("/")[0] + out_dir = os.path.join(OUT_BASE, game_id) + out_name = os.path.basename(filename) + ".mp3" + out_path = os.path.join(out_dir, out_name) + + os.makedirs(out_dir, exist_ok=True) + + if os.path.exists(out_path): + print(f" [{i}/{len(lines)}] [跳过,已存在] {filename}") + ok_count += 1 + continue + + if nar_key not in voice_ids: + print(f" [{i}/{len(lines)}] [跳过,无音色] {nar_key}: {filename}") + skip_count += 1 + continue + + print(f" [{i}/{len(lines)}][{priority}] {filename} [{nar_key}]...", end=" ", flush=True) + t0 = time.time() + + try: + wav_bytes = synthesize_voxcpm( + voice_ids[nar_key], text, + cfg_value=args.cfg, + inference_timesteps=args.steps, + ) + + tmp_wav = out_path + ".raw.wav" + with open(tmp_wav, "wb") as f: + f.write(wav_bytes) + + speed = NARRATORS[nar_key]["speed"] + apply_speed_and_convert_mp3(tmp_wav, speed, out_path) + + if os.path.exists(tmp_wav): + os.remove(tmp_wav) + + elapsed = time.time() - t0 + print(f"✓ {elapsed:.1f}s speed={speed}") + ok_count += 1 + + except Exception as e: + print(f"✗ {e}") + fail_count += 1 + + shutil.rmtree(tmp_wav_dir, ignore_errors=True) + + print(f"\n{'=' * 60}") + print(f" 完成:{ok_count} 条 跳过:{skip_count} 条 失败:{fail_count} 条") + print(f" 输出:{OUT_BASE}/{{game_id}}/*.mp3") + print(f"{'=' * 60}\n") + + +if __name__ == "__main__": + main() diff --git a/audio/check_tts_env.sh b/audio/check_tts_env.sh index 97826ed..36b8b79 100644 --- a/audio/check_tts_env.sh +++ b/audio/check_tts_env.sh @@ -9,7 +9,7 @@ set -euo pipefail HOST="${VOXCPM_HOST:-127.0.0.1}" PORT="${VOXCPM_PORT:-8000}" HEALTH_URL="http://${HOST}:${PORT}/health" -START_SCRIPT="${VOXCPM_START_SCRIPT:-/home/xsl/AutoVideo/start-voxcpm.sh}" +START_SCRIPT="${VOXCPM_START_SCRIPT:-/home/xsl/tts-server/start-voxcpm.sh}" MODEL_DIR="${VOXCPM_MODEL_DIR:-/home/xsl/models/VoxCPM2}" VENV_PYTHON="${VOXCPM_PYTHON:-/home/xsl/tts-server/.venv/bin/python}" LOG_FILE="${VOXCPM_LOG_FILE:-/tmp/voxcpm-server.log}" diff --git a/audio/gen_narrator_samples.py b/audio/gen_narrator_samples.py new file mode 100644 index 0000000..04d1016 --- /dev/null +++ b/audio/gen_narrator_samples.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +生成六个旁白音色的样本音频(Voice Design 模式,无需参考音频) + +输出目录: audio/narrator_samples/{game_id}/sample_{n}.wav +每个游戏生成 3 条有代表性的台词,覆盖不同场景类型。 +""" +import os +import time +import requests +import subprocess + +HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1") +PORT = os.environ.get("VOXCPM_PORT", "8000") +BASE_URL = f"http://{HOST}:{PORT}" +OUT_BASE = "/home/xsl/blind/audio/narrator_samples" + +# ────────────────────────────────────────────────────────────── +# 六个旁白的 Voice Design 风格描述(中文,给 VoxCPM2) +# ────────────────────────────────────────────────────────────── +NARRATORS = { + "linxia": { + "style": "成熟女声,三十岁出头,克制平静,纪录片旁白语气,语速偏慢,普通话标准,胸腔共鸣,不带情感评判", + "speed": 0.85, + "samples": [ + ("s1_narrate", "晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。"), + ("s2_tap", "你接了。妈妈的声音从电话里传来,带着一点日常的关心,问你今天怎么样,吃了没有。你说还好。挂掉之后,房间里又安静了。"), + ("s3_ending", "今晚大部分的消息,你都没有回应。你一个人度过了这个夜晚。这也是一种选择,你心里清楚。"), + ], + }, + "yinian": { + "style": "年轻女声,二十五岁左右,气声轻柔,内心独白风格,语速很慢,像说给自己听,声音贴近,偏头腔,有重量但不悲伤", + "speed": 0.80, + "samples": [ + ("s1_narrate", "你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。"), + ("s2_tap", "你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。"), + ("s3_ending", "你今晚接住了很多,你没有选择只是轻装。也许你的决定,就是带着这些往前走。"), + ], + }, + "shouye": { + "style": "中年男声,四十五岁左右,略带沙哑,低沉疲惫,实务语气,像在做交班记录,不慌张,稳定", + "speed": 0.90, + "samples": [ + ("s1_narrate", "凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。"), + ("s2_tap", "你拨了地下室的内线。没人接。你下去看了一眼,是一根管子在漏水,用旧布堵住了。回到监控室,零点二十三分。"), + ("s3_ending", "今晚你一个人上了屋顶,你看到了那只猫,你看到了天边开始变色。只有你看到了。这算不上什么,但只有你知道。"), + ], + }, + "houzhen": { + "style": "女声,完全中性,无情感,播音腔,字正腔圆,精确清晰,像医院叫号系统,句末无语调起伏", + "speed": 0.95, + "samples": [ + ("s1_narrate", "叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。"), + ("s2_tap", "你看了他一会儿。他感觉到了,朝你点了点头。你也点了点头。没有说话,但有什么东西发生了。"), + ("s3_final", "九十四号。是你。你站起来,整理了一下衣服,走向那扇门。"), + ], + }, + "yisheng": { + "style": "年轻女声,二十五岁,清晰精确,略带机械感,手机系统语音风格,有礼但冷静,像精调过的语音助手", + "speed": 0.92, + "samples": [ + ("s1_intro", "你收到了一部手机。手机的主人去世了,一个月前。他留下了很多录音,按时间排列。你不认识他。或者你以为你不认识他。"), + ("s2_meta", "第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。"), + ("s3_skip", "你跳过了这条。你永远不会知道这条说了什么了。"), + ], + }, + "fu": { + "style": "女声,大量气声,极轻,飘渺,语速极慢,声音散而不集中,像梦里的声音,若即若离", + "speed": 0.75, + "samples": [ + ("s1_intro", "你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。"), + ("s2_item", "第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。"), + ("s3_tap", "它停在你手心里,有点暖。"), + ], + }, +} + + +def synthesize_styled(text: str, style: str, cfg: float = 3.0, steps: int = 20) -> bytes: + r = requests.post( + f"{BASE_URL}/v1/speech/styled", + json={ + "text": text, + "style": style, + "voice_id": None, + "cfg_value": cfg, + "inference_timesteps": steps, + "denoise": True, + "retry_badcase": True, + "normalize": True, + }, + timeout=180, + ) + r.raise_for_status() + return r.content + + +def apply_speed(wav_in: str, speed: float, wav_out: str): + if abs(speed - 1.0) < 0.01: + import shutil; shutil.copy2(wav_in, wav_out) + return + subprocess.run([ + "ffmpeg", "-y", "-i", wav_in, + "-filter:a", f"atempo={speed}", + "-acodec", "pcm_s16le", wav_out, + ], check=True, capture_output=True) + + +def main(): + print(f"\n{'='*60}") + print(f" 旁白样本生成 — Voice Design 模式(无需参考音频)") + print(f" 输出: {OUT_BASE}/") + print(f"{'='*60}\n") + + total_ok = 0 + total_fail = 0 + + for game_id, cfg in NARRATORS.items(): + game_dir = os.path.join(OUT_BASE, game_id) + os.makedirs(game_dir, exist_ok=True) + + print(f"── {game_id} [{cfg['style'][:30]}...]") + + for tag, text in cfg["samples"]: + out_path = os.path.join(game_dir, f"{tag}.wav") + if os.path.exists(out_path): + print(f" [跳过] {tag}.wav 已存在") + total_ok += 1 + continue + + print(f" [{tag}] {text[:40]}...", end=" ", flush=True) + t0 = time.time() + + try: + wav_bytes = synthesize_styled(text, cfg["style"]) + raw_path = out_path + ".raw.wav" + with open(raw_path, "wb") as f: + f.write(wav_bytes) + + apply_speed(raw_path, cfg["speed"], out_path) + if os.path.exists(raw_path) and raw_path != out_path: + os.remove(raw_path) + + elapsed = time.time() - t0 + size_kb = os.path.getsize(out_path) // 1024 + print(f"✓ {elapsed:.1f}s {size_kb}KB") + total_ok += 1 + + except Exception as e: + print(f"✗ {e}") + total_fail += 1 + + print() + + print(f"{'='*60}") + print(f" 完成 {total_ok} 条 失败 {total_fail} 条") + print(f" 输出目录: {OUT_BASE}/") + print(f"{'='*60}\n") + + +if __name__ == "__main__": + main() diff --git a/audio/inject_narrateAudio.py b/audio/inject_narrateAudio.py new file mode 100644 index 0000000..ab2ed1e --- /dev/null +++ b/audio/inject_narrateAudio.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +为各游戏脚本注入 narrateAudio 字段 + +用法: + python audio/inject_narrateAudio.py --game shouye --dry-run + python audio/inject_narrateAudio.py --game shouye + +说明: + 扫描 audio/mp3/narrator/{game_id}/ 目录下已生成的 MP3, + 打印出对应游戏脚本中应添加的 narrateAudio 字段。 + 由于游戏脚本结构各异,本脚本只做"报告",不直接修改 JS 文件。 + +输出示例(复制粘贴到对应 scene 对象中): + s01: { + narrateAudio: '../audio/mp3/narrator/shouye/shouye_s01_narrate.mp3', + narrate: '...', // 保留原文作 TTS 兜底 + ... + } +""" +import os +import argparse +import glob + +PROJECT_DIR = "/home/xsl/blind" +NARRATOR_DIR = os.path.join(PROJECT_DIR, "audio", "mp3", "narrator") +# 相对于 game/js/games/{game}.js 的音频路径 +AUDIO_REL = "../../audio/mp3/narrator" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--game", required=True, + help="游戏 ID (linxia/yinian/shouye/houzhen/yisheng/fu)") + parser.add_argument("--dry-run", action="store_true", + help="只列出可用文件,不输出注入建议") + args = parser.parse_args() + + game_dir = os.path.join(NARRATOR_DIR, args.game) + if not os.path.isdir(game_dir): + print(f"[错误] 目录不存在: {game_dir}") + print(f"请先运行 batch_narrator_tts.py 生成音频。") + return + + files = sorted(glob.glob(os.path.join(game_dir, "*.mp3"))) + if not files: + print(f"[空] {game_dir}/ 中没有 MP3 文件。") + return + + print(f"\n已生成的 {args.game} 旁白音频 ({len(files)} 条):\n") + + if args.dry_run: + for f in files: + print(f" {os.path.basename(f)}") + return + + print("// ── 将以下 narrateAudio 字段添加到对应 scene/action 对象中 ──") + print("// (保留 narrate 文字作 TTS 兜底;有了 narrateAudio 后引擎会优先播放文件)\n") + + for f in files: + name = os.path.basename(f) # e.g. shouye_s01_narrate.mp3 + stem = name[:-4] # e.g. shouye_s01_narrate + # 推断 scene/action key + parts = stem.split("_") # ['shouye', 's01', 'narrate'] + rel_path = f"{AUDIO_REL}/{args.game}/{name}" + + # 判断类型 + if parts[-1] == "narrate" or parts[-1].startswith("narrate"): + field = "narrateAudio" + note = f"// → scene {parts[1]}" + elif parts[-1] == "tap" or parts[-1].startswith("tap"): + field = "narrateAudio" + note = f"// → scene {parts[1]} / tap" + elif parts[-1] == "dt": + field = "narrateAudio" + note = f"// → scene {parts[1]} / doubleTap" + elif parts[-1] == "intro": + field = "// game.introAudio" + note = "// → game 对象 intro 字段(engine.run 中用 narrateAudio 处理)" + elif "ending" in parts: + ending_id = "_".join(parts[parts.index("ending"):]) + field = "narrateAudio" + note = f"// → endings.{ending_id.replace('ending_', '')} 对象" + else: + field = "narrateAudio" + note = "" + + print(f" {field}: '{rel_path}', {note}") + + print("\n// 提示:intro 的 narrateAudio 需在 engine.run() 里单独处理,") + print("// 或在 game 对象中加 introAudio 字段并在 engine 中检查。") + + +if __name__ == "__main__": + main() diff --git a/audio/narrator_samples/fu/s1_intro.wav b/audio/narrator_samples/fu/s1_intro.wav new file mode 100644 index 0000000..e598f47 Binary files /dev/null and b/audio/narrator_samples/fu/s1_intro.wav differ diff --git a/audio/narrator_samples/fu/s2_item.wav b/audio/narrator_samples/fu/s2_item.wav new file mode 100644 index 0000000..d819b1a Binary files /dev/null and b/audio/narrator_samples/fu/s2_item.wav differ diff --git a/audio/narrator_samples/fu/s3_tap.wav b/audio/narrator_samples/fu/s3_tap.wav new file mode 100644 index 0000000..6adb758 Binary files /dev/null and b/audio/narrator_samples/fu/s3_tap.wav differ diff --git a/audio/narrator_samples/houzhen/s1_narrate.wav b/audio/narrator_samples/houzhen/s1_narrate.wav new file mode 100644 index 0000000..125a3ba Binary files /dev/null and b/audio/narrator_samples/houzhen/s1_narrate.wav differ diff --git a/audio/narrator_samples/houzhen/s2_tap.wav b/audio/narrator_samples/houzhen/s2_tap.wav new file mode 100644 index 0000000..f9d3af8 Binary files /dev/null and b/audio/narrator_samples/houzhen/s2_tap.wav differ diff --git a/audio/narrator_samples/houzhen/s3_final.wav b/audio/narrator_samples/houzhen/s3_final.wav new file mode 100644 index 0000000..b67bc32 Binary files /dev/null and b/audio/narrator_samples/houzhen/s3_final.wav differ diff --git a/audio/narrator_samples/linxia/s1_narrate.wav b/audio/narrator_samples/linxia/s1_narrate.wav new file mode 100644 index 0000000..2c77531 Binary files /dev/null and b/audio/narrator_samples/linxia/s1_narrate.wav differ diff --git a/audio/narrator_samples/linxia/s2_tap.wav b/audio/narrator_samples/linxia/s2_tap.wav new file mode 100644 index 0000000..f5f390f Binary files /dev/null and b/audio/narrator_samples/linxia/s2_tap.wav differ diff --git a/audio/narrator_samples/linxia/s3_ending.wav b/audio/narrator_samples/linxia/s3_ending.wav new file mode 100644 index 0000000..f09d5f3 Binary files /dev/null and b/audio/narrator_samples/linxia/s3_ending.wav differ diff --git a/audio/narrator_samples/shouye/s1_narrate.wav b/audio/narrator_samples/shouye/s1_narrate.wav new file mode 100644 index 0000000..fb8c00b Binary files /dev/null and b/audio/narrator_samples/shouye/s1_narrate.wav differ diff --git a/audio/narrator_samples/shouye/s2_tap.wav b/audio/narrator_samples/shouye/s2_tap.wav new file mode 100644 index 0000000..414ca34 Binary files /dev/null and b/audio/narrator_samples/shouye/s2_tap.wav differ diff --git a/audio/narrator_samples/shouye/s3_ending.wav b/audio/narrator_samples/shouye/s3_ending.wav new file mode 100644 index 0000000..517bc6b Binary files /dev/null and b/audio/narrator_samples/shouye/s3_ending.wav differ diff --git a/audio/narrator_samples/yinian/s1_narrate.wav b/audio/narrator_samples/yinian/s1_narrate.wav new file mode 100644 index 0000000..75dbe26 Binary files /dev/null and b/audio/narrator_samples/yinian/s1_narrate.wav differ diff --git a/audio/narrator_samples/yinian/s2_tap.wav b/audio/narrator_samples/yinian/s2_tap.wav new file mode 100644 index 0000000..5605221 Binary files /dev/null and b/audio/narrator_samples/yinian/s2_tap.wav differ diff --git a/audio/narrator_samples/yinian/s3_ending.wav b/audio/narrator_samples/yinian/s3_ending.wav new file mode 100644 index 0000000..0c1840d Binary files /dev/null and b/audio/narrator_samples/yinian/s3_ending.wav differ diff --git a/audio/narrator_samples/yisheng/s1_intro.wav b/audio/narrator_samples/yisheng/s1_intro.wav new file mode 100644 index 0000000..56fb371 Binary files /dev/null and b/audio/narrator_samples/yisheng/s1_intro.wav differ diff --git a/audio/narrator_samples/yisheng/s2_meta.wav b/audio/narrator_samples/yisheng/s2_meta.wav new file mode 100644 index 0000000..37ebcc1 Binary files /dev/null and b/audio/narrator_samples/yisheng/s2_meta.wav differ diff --git a/audio/narrator_samples/yisheng/s3_skip.wav b/audio/narrator_samples/yisheng/s3_skip.wav new file mode 100644 index 0000000..4702fb0 Binary files /dev/null and b/audio/narrator_samples/yisheng/s3_skip.wav differ diff --git a/docs/旁白音色提示词.md b/docs/旁白音色提示词.md new file mode 100644 index 0000000..b0f5f1f --- /dev/null +++ b/docs/旁白音色提示词.md @@ -0,0 +1,483 @@ +# 盲游 · 旁白音色提示词文档 + +> 本文档为六个游戏旁白的音色定向文件。 +> 用途:根据此文档寻找参考音频,或向 TTS 模型输入提示词,生成样本后人工筛选。 +> 每个游戏的旁白声音独立,风格、质感、情绪基调均不同。 + +--- + +## 通用技术要求 + +生成的样本音频需满足: + +- 格式:WAV,16kHz,单声道(作为参考音色注册用) +- 最终产出:MP3,44100Hz,单声道,128kbps,-18 LUFS +- 首尾静音:开头 100ms,结尾 200ms +- 无背景音乐,无混响(干声) + +--- + +## 一、《林夏》旁白 — 纪录片叙述者 + +### 角色定位 + +她是一个旁观者,不是林夏本人,也不是林夏的朋友。她只是在记录。她知道今晚发生了什么,但她不评价,不安慰,不引导。像一部安静的纪录片里的解说词。 + +### 声音特征 + +| 维度 | 描述 | +|------|------| +| 性别 | 女声 | +| 年龄感 | 30-38岁 | +| 音色底质 | 中低女声,有一点点气声,但不是耳语。清晰,不尖锐 | +| 共鸣位置 | 胸腔共鸣为主,不是头声 | +| 语速 | 慢,比正常对话语速慢约15%。每个短句之间有自然停顿 | +| 音调 | 标准普通话音调,不夸张,没有朗诵腔 | +| 气息 | 平稳,每句话起头不会有明显换气声 | + +### 情感基调 + +**克制的悲悯**。她知道林夏今晚过得不容易,但她不会在语气上表现出来。那种情绪藏在语速和停顿里,不在音调变化里。 + +**不该有的感觉**:同情、叹息、戏剧性、新闻播报腔、心理咨询语气。 + +### TTS 提示词(英文,适用于支持风格描述的模型) + +``` +Voice: Adult Chinese female, 30-38 years old. Documentary narrator style. +Tone: Calm, observational, restrained compassion. Never dramatic. +Delivery: Slow pace (85% of normal speed), natural pauses between clauses. +Timbre: Mid-low register, slight chest resonance, not breathy, not sharp. +Emotion: Neutral on the surface, with a barely perceptible weight underneath. +Language: Standard Mandarin Chinese, no regional accent, no theatrical intonation. +Do NOT sound like: news anchor, audiobook reader, customer service, therapist. +Reference: Like a quiet documentary about ordinary people, late at night. +``` + +### TTS 提示词(中文) + +``` +声音风格:中国女性旁白,年龄感在三十岁出头。纪录片叙述者风格。 +语气:平静、观察性的,有克制的悲悯,但绝不戏剧化。 +语速:慢,约正常语速的85%,句与句之间有自然停顿。 +音色:中低女声,以胸腔共鸣为主,略有气声但不是耳语,吐字清晰。 +情感:表面中性,但语速和停顿里带着一点重量。 +语言:标准普通话,无地方口音,无朗诵腔。 +不要像:新闻主播、有声书朗读、客服提示音、心理咨询师。 +参考感受:像一部关于普通人日常的安静纪录片,深夜播出。 +``` + +### 样例句子及朗读标注 + +``` +「晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。」 + +朗读提示: +- 「晚上九点零三分。」— 陈述,轻,后面停顿约 0.5 秒 +- 「手机震动,铃声响起。」— 音量略低,描述动作,不强调 +- 「屏幕显示:」— 停顿,然后 +- 「妈妈。」— 非常平,两个字,不带任何情感色彩 +``` + +``` +「铃声响了又停。妈妈没再打来。」 + +朗读提示: +- 整句语速偏慢,结尾「没再打来」后留出3秒的静默感 +- 不要读出遗憾或心疼,只是陈述事实 +``` + +--- + +## 二、《一念》旁白 — 内心独白 + +### 角色定位 + +她就是你自己。不是外部的声音,是你坐在黑暗里,自己在心里说话的声音。像半梦半醒之间,对自己说的那些不会说出口的话。 + +### 声音特征 + +| 维度 | 描述 | +|------|------| +| 性别 | 女声 | +| 年龄感 | 25-32岁 | +| 音色底质 | 略有气声,不是正常说话声而是「心里的声音」。比林夏更柔,更近 | +| 共鸣位置 | 偏向头腔和鼻腔,不是胸腔 | +| 语速 | 很慢,约正常语速的80%,每个词之间都有微小的停顿,像在慢慢想 | +| 音调 | 比标准说话略低半个音阶,平稳,很少起伏 | +| 气息 | 有轻微的气声底色,每句开头偶尔有一点呼气感,但不是叹气 | +| 距离感 | 非常近,像贴着耳朵说话,但不是低吼,是轻而有质感 | + +### 情感基调 + +**亲密与重量**。这个声音不是在讲述,是在陪伴。她知道你在想什么,她不急着说完,每一句都给你留足了空间去感受。 + +**不该有的感觉**:治愈系播客腔、催眠音频的夸张柔和、冥想引导语气。 + +### TTS 提示词(英文) + +``` +Voice: Chinese female, 25-32 years old. Inner monologue, the voice inside your own head. +Tone: Intimate, slow, weighted. Like whispering to yourself in the dark. +Delivery: Very slow (80% speed), micro-pauses between phrases, thoughtful cadence. +Timbre: Slightly breathy, soft head resonance, close and present — not distant. +Emotion: Quiet tenderness, not sad but carrying weight. Like someone who knows. +Distance: Very close mic presence, like speaking directly into the listener's ear. +Do NOT sound like: meditation app, ASMR, therapy podcast, bedtime story reader. +Reference: The internal voice you hear when reading something that moved you deeply. +``` + +### TTS 提示词(中文) + +``` +声音风格:中国女性,年龄感25-32岁。内心独白,就是你自己脑海中的声音。 +语气:亲密、缓慢、有重量。像在黑暗中对自己说话。 +语速:很慢,约正常语速的80%,词与词之间有细小停顿,像在慢慢思考。 +音色:略有气声,偏头腔共鸣,声音贴近,不是从远处传来的。 +情感:安静的温柔,不悲伤但有分量,像一个懂你的人。 +距离感:非常近,像直接说在耳边,但不是低吼。 +不要像:冥想App引导词、ASMR、心理播客、睡眠故事。 +参考感受:读一本让你动容的书时,脑海中浮现的那个声音。 +``` + +### 样例句子及朗读标注 + +``` +「你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。」 + +朗读提示: +- 「你坐在黑暗里,」— 极轻,像呼气 +- 「独自一人。」— 停顿感,留白约 0.8 秒 +- 「今晚你必须做一个决定——」— 稍慢,破折号处停顿约 1 秒 +- 「你自己知道是什么。」— 最轻,几乎是说给自己听的 +``` + +``` +「你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。」 + +朗读提示: +- 「有什么东西松开了。」— 这是最重要的一句,但要说得最轻, + 像呼出一口气,结尾略微延长「了」的尾音 +``` + +--- + +## 三、《守夜》旁白 — 对讲机留言 + +### 角色定位 + +他是一个老保安,在你入职之前就在这栋楼里工作了很多年。他提前录好了这些提示音,放在对讲机系统里,帮助新来的保安在夜班时做判断。他见过很多事,不大惊小怪,但他认真负责。 + +### 声音特征 + +| 维度 | 描述 | +|------|------| +| 性别 | 男声 | +| 年龄感 | 45-55岁 | +| 音色底质 | 中低男声,略带沙哑,像抽了很多年烟但已经戒了。不是醇厚的磁性男声,是有点磨损的质感 | +| 共鸣位置 | 以胸腔为主,厚实但不夸张 | +| 语速 | 接近正常,约90%语速,但字与字之间咬字清楚,不含糊 | +| 音调 | 标准偏低,不高亢,像在做记录而不是在讲故事 | +| 录音质感 | 可以有轻微的对讲机感(略带中频突出),但要保持清晰 | +| 气息 | 平稳,带一点疲惫,但是那种习惯了疲惫的人,不是撑不住的那种 | + +### 情感基调 + +**实务与温和**。他的语气像在交接班,说清楚情况,给你判断,但他不会给你施压。他见过太多了,什么情况他都经历过,所以他的语气里有一种稳定的疲惫,不是焦虑的那种疲惫。 + +**不该有的感觉**:权威、命令、惊慌、新闻播报腔、机械警报感。 + +### TTS 提示词(英文) + +``` +Voice: Middle-aged Chinese male, 45-55 years old. Pre-recorded walkie-talkie message. +Tone: Matter-of-fact, mildly tired, steady. Like a veteran security guard giving handover notes. +Delivery: Near-normal pace (90% speed), clear diction, slight deliberateness. +Timbre: Mid-low male voice, slightly gravelly/worn texture, chest resonance dominant. +Emotion: Calm fatigue. Not stressed, not urgent. Seen it all before. +Audio texture: Slight mid-frequency presence (like walkie-talkie), but still clear. +Do NOT sound like: military commander, news anchor, emergency alert, thriller narrator. +Reference: An experienced night-shift worker leaving a recorded message for the next shift. +``` + +### TTS 提示词(中文) + +``` +声音风格:中年中国男性,年龄感45-55岁。预录的对讲机提示音。 +语气:实务、略带疲惫、稳定。像一个经验丰富的保安在交班时留下的提示。 +语速:接近正常,约90%,吐字清楚,有一点郑重但不沉重。 +音色:中低男声,略带沙哑/磨损感,以胸腔共鸣为主。 +情感:平静的疲惫。不紧张,不急迫,什么都见过了。 +音频质感:轻微的中频突出(对讲机感),但保持清晰可辨。 +不要像:军事指挥官、新闻主播、紧急广播、惊悚片旁白。 +参考感受:一个在这栋楼工作了二十年的老保安,录了一批提示音留给新来的年轻人。 +``` + +### 样例句子及朗读标注 + +``` +「凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。」 + +朗读提示: +- 「凌晨零点十分。」— 像在说时间记录,平,有点像报时 +- 「地下室传来水声,」— 陈述,不强调 +- 「不大,」— 略停,「但是」 +- 「持续的,一直在响。」— 这三个字是关键信息,稍重,但不夸张 +``` + +``` +「五点整。接班的人进来了,你们交接了几句,递了记录本,你背上包走出去。外面已经有一点点亮了。」 + +朗读提示: +- 前半部分正常陈述 +- 「外面已经有一点点亮了。」— 这里语气应该有一丝松动, + 不是高兴,是那种熬完一夜之后的轻,但他说的时候还是很平 +``` + +--- + +## 四、《候诊》旁白 — 时间本身 + +### 角色定位 + +这个声音没有性格,没有情绪,没有立场。它就是时间在说话。精确,中性,像一个永远不会犯错的计时器,或者一个医院里的叫号系统升级版。它不在意你的感受,它只在意事实。 + +### 声音特征 + +| 维度 | 描述 | +|------|------| +| 性别 | 女声或男声均可,推荐女声(更贴近中性)| +| 年龄感 | 难以判断年龄,像是AI生成的那种无年龄感 | +| 音色底质 | 非常干净,没有气声,没有磨损,没有个人质感。标准播音腔,但去掉情感成分 | +| 共鸣位置 | 中性共鸣,不特别强调胸腔或头腔 | +| 语速 | 接近正常,约95%,每个数字、每个名词都精确清晰 | +| 音调 | 几乎完全平,句末不做语气下沉或上扬 | +| 气息 | 完全平稳,机械般的平稳 | + +### 情感基调 + +**零情感,精确**。不冷漠(冷漠也是一种情感),就是没有情感。像GPS语音导航,但更安静,更精准,没有任何「友好感」。 + +**不该有的感觉**:温暖、关怀、急迫、权威感、新闻感、AI助手的那种刻意亲切。 + +### TTS 提示词(英文) + +``` +Voice: Chinese female (preferred) or male, genderless age quality. +Tone: Completely neutral. Zero emotion. Not cold — simply emotionless. +Delivery: Near-normal pace (95%), extremely precise diction, especially for numbers and names. +Timbre: Clean, no breathiness, no texture, no warmth — pure phonation. +Emotion: None. This is time itself speaking. It does not care. +Intonation: Nearly flat. Minimal sentence-final variation. +Do NOT sound like: friendly AI assistant, GPS voice, news anchor, customer service bot. +Reference: A hospital queue number system, but more articulate and slower. +``` + +### TTS 提示词(中文) + +``` +声音风格:中国女声(推荐)或男声,无年龄感。 +语气:完全中性。零情感。不是冷漠,而是没有情感这件事本身。 +语速:接近正常,约95%,数字和名词发音极其精确清晰。 +音色:干净,无气声,无质感,无温度——纯粹的发声。 +情感:无。这是时间本身在说话,它不在乎任何人。 +语调:近乎完全平,句末几乎没有语调变化。 +不要像:友好的AI助手、GPS导航语音、新闻主播、客服机器人的刻意亲切。 +参考感受:医院的叫号系统,但更清晰,更慢,句子更长。 +``` + +### 样例句子及朗读标注 + +``` +「叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。」 + +朗读提示: +- 「叫号机叫到了八十五号。」— 像读数据 +- 「你的号是九十四。」— 完全平,不带任何「所以你还要等」的暗示 +- 后面的描述性句子:同样平,但语速可以微微放慢,因为句子更长 +- 整句没有任何起伏,连「结实」也不强调 +``` + +``` +「九十四号。是你。你站起来,整理了一下衣服,走向那扇门。」 + +朗读提示: +- 「九十四号。」— 像报号,平 +- 「是你。」— 两个字,无任何情感,不是「终于是你了」,只是确认 +- 后面的动作描述:陈述,像摄像头记录下的画面 +``` + +--- + +## 五、《遗声》旁白 — 手机系统语音 + +### 角色定位 + +她是这部手机的语音助手,被主人调教过,用来播报录音元数据。她有一点点温度,但本质上还是机器——她不知道这些录音是什么内容,她只负责读出文件信息。如果说《候诊》的旁白是零温度,她大约是温度计刚刚能感应到体温的那一格。 + +### 声音特征 + +| 维度 | 描述 | +|------|------| +| 性别 | 女声 | +| 年龄感 | 25-30岁的感觉,但带一点点机械感 | +| 音色底质 | 偏年轻的女声,咬字非常清晰,字字分明,但没有过度的感情投入 | +| 共鸣位置 | 偏头腔,亮而清晰 | +| 语速 | 约92%正常语速,读数字和文件名时会自然地慢下来一点 | +| 音调 | 略高于标准说话音调,但只高一点点 | +| 气息 | 完全清晰,无气声,像数字合成人声 | +| 节奏感 | 元数据的各个部分之间有固定的小停顿,像在读表格 | + +### 情感基调 + +**冷静、有礼、略机械**。她不是真的无情,但她的情感是预设的,是「礼貌」这个程序的一部分,而不是真正感受到了什么。像一个很专业的前台,完全不会失礼,但你知道她下班之后不会想起你。 + +**不该有的感觉**:人类的共情感、悲伤(即使读到沉重的文件名也不会)、夸张的热情、恶意的冷漠。 + +### TTS 提示词(英文) + +``` +Voice: Young Chinese female, 25-30 years old. Phone voice assistant style. +Tone: Calm, polite, slightly mechanical — but with a tiny hint of warmth (not cold, not human). +Delivery: 92% speed, very precise diction, slight deliberate pauses between metadata fields. +Timbre: Bright head resonance, clean, no breathiness — digital synthesis quality. +Emotion: Programmed politeness. Not sad reading sad file names. Not happy reading happy ones. +Rhythm: Consistent, like reading a form — date, duration, filename as separate units. +Do NOT sound like: warm human narrator, empathetic counselor, cold robot, customer service. +Reference: Apple's Siri or a refined voice assistant, but quieter and slower. +``` + +### TTS 提示词(中文) + +``` +声音风格:年轻中国女性,年龄感25-30岁。手机语音助手风格。 +语气:冷静、有礼、轻微的机械感——有一点点温度,但不是人类的温度。 +语速:约92%,咬字极其清晰,元数据各字段之间有固定小停顿。 +音色:偏头腔共鸣,明亮清晰,无气声——接近数字合成人声的质感。 +情感:程序化的礼貌。读到沉重的文件名不会悲伤,读到轻松的也不会高兴。 +节奏:稳定,像在读表格——日期、时长、文件名分别是独立的信息单元。 +不要像:温暖的人类旁白、有共情的叙述者、冷酷的机器、客服话务员。 +参考感受:Apple Siri或精调后的语音助手,但更安静、更慢。 +``` + +### 样例句子及朗读标注 + +``` +「第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。」 + +朗读提示: +- 「第一条录音。」— 轻,像索引号 +- 「录制时间:」— 停顿,然后读出日期,日期之后停顿 +- 「三月五日,早上八点。」— 数字精确,停顿 +- 「时长:」— 同样格式 +- 「二十二秒。」— 精确,停顿 +- 「文件名:」— 停顿 +- 「买橙子。」— 读文件名时没有任何情感色彩,就算是再奇怪的文件名也一样平 +``` + +--- + +## 六、《浮》旁白 — 梦里的声音 + +### 角色定位 + +这个声音几乎不存在。它是梦里的,不知道从哪里来,没有身份,没有位置。它不是在"说话",是在"飘"。像你半睡半醒时听到的窗外风声,你以为自己听到了什么词,但你不确定。 + +### 声音特征 + +| 维度 | 描述 | +|------|------| +| 性别 | 女声 | +| 年龄感 | 难以判断,可能是任何年龄 | +| 音色底质 | 大量气声,几乎一半是气一半是声。不是正常的气声,是那种声音里大部分是呼吸的质感 | +| 共鸣位置 | 几乎没有共鸣,声音不集中,是「散」的 | +| 语速 | 很慢,约75%正常语速,某些词会自然地拖长 | +| 音调 | 比正常说话略低,非常平,起伏极小,像水面微微荡漾 | +| 气息 | 这个角色的声音本身就是气息。每句话开头都有轻微的吸气或呼气 | +| 空间感 | 有一点点远,像从很远的地方传来,或者像在一个大的空旷空间里 | +| 混响 | 可以有轻微的自然混响(不是KTV那种),像在一个大房间里或水边 | + +### 情感基调 + +**飘渺与悬浮**。这个声音没有紧迫感,没有目的感,它只是在漂,说了什么它自己也不在意是否被听到。但每一句话都有它的重量,只是那个重量是用气息而不是用音量表现的。 + +**不该有的感觉**:ASMR(太刻意)、冥想引导(太有目的性)、鬼魂感(不是恐怖的)、梦境里的哲学大师。 + +### TTS 提示词(英文) + +``` +Voice: Chinese female, age indeterminate. The voice of a dream — barely present. +Tone: Floating, weightless, present but not insistent. It does not need to be heard. +Delivery: Very slow (75% speed), some words naturally elongated, long pauses between sentences. +Timbre: High breathy ratio — the voice is mostly breath with a thin thread of tone. +Resonance: Minimal, diffuse — not focused, like sound in an open space or on water. +Emotion: Serene vacancy. Aware but not attached. Like mist. +Space: Slightly distant, with subtle natural reverb (not studio dry, not echoey). +Do NOT sound like: ASMR content creator, meditation guide, horror movie ghost, poetic sage. +Reference: The voice you think you hear right before falling asleep, uncertain if it was real. +``` + +### TTS 提示词(中文) + +``` +声音风格:中国女性,无法判断年龄。梦里的声音,几乎不存在。 +语气:漂浮、无重力、在场但不坚持。它不在意自己有没有被听到。 +语速:很慢,约75%正常语速,某些词会自然地拖长,句与句之间停顿很长。 +音色:气声比例极高——声音大部分是呼吸,带着一缕细细的音调。 +共鸣:极少,声音是散的,不集中,像在开阔空间或水面上的声音。 +情感:宁静的空洞。有意识但不执着,像雾。 +空间感:略远,带轻微的自然混响(不是录音棚的干声,也不是大回声)。 +不要像:ASMR创作者、冥想引导、恐怖片鬼声、诗意的哲学家。 +参考感受:将睡未睡时,你以为听到了什么声音,但不确定是不是真的。 +``` + +### 样例句子及朗读标注 + +``` +「你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。」 + +朗读提示: +- 几乎每个逗号后都停顿 1 秒以上 +- 「不知道是水,」— 语速拖长,「水」字尾音延长 +- 「还是梦,」— 更轻,再延长 +- 「还是别的什么。」— 最轻,像消失前的最后一丝声音 +- 「声音会漂过来。」— 新的一句,但不重,像是在说梦话 +``` + +``` +「第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。」 + +朗读提示: +- 「第一个。」— 很轻,像梦里的报数 +- 「像夏天傍晚的风,」— 语速极慢,「风」字略微拖长 +- 「带着草的气味,」— 继续慢,像真的闻到了什么 +- 「你说不清是哪年夏天的。」— 这句有一点感慨,但要控制,气声增加 +- 「它停在你身边了。」— 最轻,最气声 +``` + +--- + +## 附:音色对比速查表 + +| 游戏 | 关键词 | 最像 | 最不像 | +|------|--------|------|--------| +| 林夏 | 克制、观察、平静 | 深夜纪录片 | 心理咨询、有声书 | +| 一念 | 亲密、缓慢、有重量 | 你自己的内心声音 | 冥想App、ASMR | +| 守夜 | 实务、疲惫、稳定 | 老同事交班提示 | 军事指挥、新闻主播 | +| 候诊 | 精确、中性、零情感 | 医院叫号系统 | AI助手的亲切感 | +| 遗声 | 有礼、清晰、略机械 | 精调版Siri | 温暖人类旁白 | +| 浮 | 气声、飘渺、悬浮 | 将睡未睡时的幻听 | ASMR创作者 | + +--- + +## 样本评估标准 + +收到生成样本后,按以下维度打分(每项 1-5 分): + +1. **音色符合度** — 和提示词描述的质感是否匹配 +2. **情感准确度** — 情感基调是否正确(不多也不少) +3. **语速节奏** — 是否有自然的停顿,不赶场 +4. **清晰度** — 中文吐字是否清晰,特别是数字、专有名词 +5. **辨识度** — 六个旁白之间是否有足够的声音差异 + +优先确保林夏(主游戏)和浮(风格最特殊)的样本质量。 diff --git a/docs/音频制作执行手册.md b/docs/音频制作执行手册.md new file mode 100644 index 0000000..f4d0390 --- /dev/null +++ b/docs/音频制作执行手册.md @@ -0,0 +1,315 @@ +# 盲游 · 音频制作执行手册 + +> 本手册是**从零制作一款游戏音频**的操作指南,覆盖配音、音乐、音效三类。 +> 所有音频均由本机 AI 模型本地生成,不依赖任何真人录音或网络素材。 +> 林夏项目将按本手册完全重头制作。 + +--- + +## 〇、环境速查(已就绪,无需再装) + +| 类别 | 工具 | 路径 / 环境 | 用途 | +|------|------|------------|------| +| 基础 | FFmpeg 7.0 | `/usr/local/bin/ffmpeg` | 格式转换、响度归一化、听筒效果 | +| 基础 | Python 3.13 + conda | `/home/xsl/miniconda3` | 脚本运行 | +| GPU | RTX 5090 32GB | — | 模型推理 | +| **配音** | **VoxCPM2** | `/home/xsl/tts-server` · 端口 **8000** | TTS 主链路(Voice Design + 克隆) | +| 配音-备 | CosyVoice 2.0 | `/home/xsl/tools/CosyVoice` · conda `cosyvoice` | TTS 备用 | +| 配音-备 | GPT-SoVITS | `/home/xsl/tools/GPT-SoVITS` · conda `gptsovits` | TTS 备用 | +| 配音-备 | fish-speech | `/home/xsl/tools/fish-speech` · conda `fishspeech` | TTS 备用 | +| **音乐** | **ACE-Step 1.5** | `/home/xsl/tools/ACE-Step-1.5` · conda `acestep` | BGM + 铃声生成 | +| **音效** | ACE-Step 1.5 | 同上 | 氛围类音效(≥5s) | +| 音效-补 | Stable Audio Open | `/home/xsl/tools/stable-audio-tools` · conda `stableaudio` | 高品质音效补充 | +| UI 音效 | Web Audio API | 浏览器端 `audio.js` 合成 | 短促反馈音(无文件) | + +**模型权重**:VoxCPM2 @ `/home/xsl/models/VoxCPM2` + +> 端口说明:VoxCPM2 服务固定使用 **8000**(`/home/xsl/tts-server` 不动)。项目脚本通过 `VOXCPM_HOST`/`VOXCPM_PORT` 环境变量读取,默认 8000,已全部对齐。 + +--- + +## 一、总体流程(三类音频统一节奏) + +``` +准备内容(音色提示词 + 台词表 + BGM/音效 caption) + │ + ├── 配音 ─→ 启动TTS ─→ 生成音色样本 ─→ 选定音色 ─→ 克隆生成台词 ─→ 归一化 + ├── 音乐 ─→ 启动ACE-Step ─→ 生成 ─→ 归一化 + └── 音效 ─→ 启动ACE-Step ─→ 生成 ─→ 归一化 + │ + └── 统一归一化(process_audio.sh)──→ 部署到 audio/mp3/ +``` + +**关键原则——TTS 配音两步走**:先用提示词生成音色样本 → 人工筛选 → 再用选定音色克隆生成正式配音。不要跳过选音色这一步。 + +--- + +## 二、配音制作(TTS / VoxCPM2) + +### 2.1 启动 TTS 服务 + +```bash +# 方式一:直接启动 +bash /home/xsl/tts-server/start-voxcpm.sh +# 验证 +curl http://127.0.0.1:8000/health +# 预期: {"status":"ok","model":"VoxCPM2","styled_speech":true} + +# 方式二:环境检查 + 自动启动(含降噪模型回退) +bash /home/xsl/blind/audio/check_tts_env.sh +bash /home/xsl/blind/audio/check_tts_env.sh --full # 含全链路测试合成 +``` + +> 若降噪模型下载失败,脚本会自动回退到 `VOXCPM_ENABLE_DENOISER=0` 重试(更稳,降噪关闭)。 + +### 2.2 第一步:生成音色样本(Voice Design) + +**输入**:角色的音色提示词(风格描述)。 +**参考文档**:`docs/旁白音色提示词.md`(旁白)、`other_docs/林夏_原始设计.md` §7.3(角色音色配置表)。 + +```bash +# 生成六个旁白的音色样本(无需参考音频,纯 Voice Design) +cd /home/xsl/blind +python3 audio/gen_narrator_samples.py +# 输出: audio/narrator_samples/{game_id}/sample_*.wav +``` + +若要为新游戏/新角色生成样本,修改 `gen_narrator_samples.py` 中的 `NARRATORS` 字典:填入 `style`(音色提示词)、`speed`(语速)、`samples`(代表性台词)。 + +### 2.3 人工筛选音色 + +1. 试听 `audio/narrator_samples/{game_id}/` 下的 WAV +2. 按 `docs/旁白音色提示词.md` 末尾的"样本评估标准"打分(音色符合度、情感准确度、语速节奏、清晰度、辨识度) +3. 选定一个样本,作为该角色/旁白的**音色锚点** + +### 2.4 第二步:克隆生成正式配音 + +**将选定音色放入参考目录**: + +``` +voice_mp3/narrator/{game_id}/ # 或 voice/narrator/{game_id}/(WAV) + └── 选定的音色样本文件 +``` + +**批量生产旁白配音**: + +```bash +# 生产所有游戏旁白 +python3 audio/batch_narrator_tts.py --source voice_mp3 + +# 只生产指定游戏 +python3 audio/batch_narrator_tts.py --source voice_mp3 --game linxia + +# 只生产 P0(narrate/tap/dt/intro,核心体验) +python3 audio/batch_narrator_tts.py --source voice_mp3 --priority p0 + +# 干跑(先看任务列表,不实际合成) +python3 audio/batch_narrator_tts.py --source voice_mp3 --dry-run +``` + +输出:`audio/00_raw/narrator/{game_id}/{game_id}_{scene_id}_{type}.wav` + +**批量生产角色配音(林夏角色台词)**: + +```bash +# 林夏角色台词(需在脚本 LINES 中维护台词表) +python3 audio/batch_tts_voxcpm.py --source voice_mp3 --dry-run # 先看任务 +python3 audio/batch_tts_voxcpm.py --source voice_mp3 # 正式生产 +python3 audio/batch_tts_voxcpm.py --source voice_mp3 --role mom # 只生产某角色 +``` + +输出:`audio/00_raw/tts/{filename}_wav_v1.wav` + +> 台词表在 `batch_tts_voxcpm.py` 的 `LINES` 变量中维护;参考音色在 `voice_mp3/{角色}/` 或 `voice/{角色}/` 下。重做林夏时需重新整理台词表。 + +### 2.5 VoxCPM2 API(手动单条合成时用) + +| 接口 | 用途 | +|------|------| +| `POST /v1/voices` | 注册参考音频(wav_base64)→ 返回 voice_id | +| `POST /v1/speech` | 纯克隆合成(text + voice_id)→ WAV | +| `POST /v1/speech/styled` | Style Control(有 voice_id=克隆+表演;无=Voice Design) | + +参数:`cfg_value`(默认 2.0)、`inference_timesteps`(默认 10);输出 48kHz 16-bit PCM WAV。 + +--- + +## 三、音乐制作(BGM + 铃声 / ACE-Step 1.5) + +### 3.1 启动环境 + +```bash +conda activate acestep +``` + +### 3.2 生成 + +修改 `audio/gen_music_acestep.py` 中的 `BGM_TRACKS`(每段填 `filename`、`duration`、`caption`、`seed`),然后: + +```bash +# 生成全部(BGM + 铃声) +python3 audio/gen_music_acestep.py + +# 只生成 BGM +python3 audio/gen_music_acestep.py --category bgm + +# 只生成铃声 +python3 audio/gen_music_acestep.py --category ringtone + +# 干跑 +python3 audio/gen_music_acestep.py --dry-run +``` + +输出:`audio/00_raw/music/{filename}.wav` + +> RTX 5090 已在脚本顶部做了 cuBLAS 修复:`torch.backends.cuda.preferred_blas_library("cublaslt")`,无需额外处理。 + +### 3.3 BGM caption 示例(参考林夏已有) + +```python +{ + "filename": "bgm_main_loop", + "duration": 90, + "caption": "lo-fi midnight bedroom, soft piano, light vinyl crackle, muted piano, subtle synth pad, occasional distant rain, melancholy but warm, ambient loop, no drums, 70 bpm", + "lyrics": "", + "steps": 20, + "seed": 2103, +} +``` + +caption 越具体,生成质量越稳定。建议包含:风格、乐器、情绪、节奏、是否带人声、bpm。 + +--- + +## 四、音效制作(ACE-Step 1.5) + +### 4.1 适用范围 + +- **ACE-Step 可生成**:≥5s 的氛围类音效(呼吸、心跳、转场 swell、环境底噪等) +- **需另行处理**:短促 UI 音效(叮、咚、点击)—— 由 `audio.js` 的 Web Audio 合成,无需文件 +- **补充**:可用 Stable Audio Open 生成 ACE-Step 不擅长的音效 + +### 4.2 生成 + +修改 `audio/gen_sfx_acestep.py` 中的 `SFX_TRACKS`(填 `filename`、`duration`、`caption`、`seed`),然后: + +```bash +conda activate acestep +python3 audio/gen_sfx_acestep.py # 正式生成 +python3 audio/gen_sfx_acestep.py --dry-run # 干跑 +``` + +输出: +- 音效 → `audio/00_raw/sfx/{filename}.wav` +- 环境音 → `audio/00_raw/ambience/{filename}.wav` + +--- + +## 五、后期归一化(统一处理) + +所有原始音频生成后,用 `process_audio.sh` 统一做响度归一化、听筒效果、格式转换: + +```bash +cd /home/xsl/blind +bash audio/process_audio.sh tts # 配音(含电话/语音听筒效果) +bash audio/process_audio.sh music # BGM + 铃声 +bash audio/process_audio.sh sfx # 音效 +bash audio/process_audio.sh ambience # 环境音 +bash audio/process_audio.sh all # 全部 +``` + +### 各类响度标准 + +| 类别 | 响度 | 额外处理 | +|------|------|---------| +| TTS 配音 | -16 LUFS | 电话/语音类加听筒效果(highpass+lowpass+EQ);长语音分段拼接 | +| BGM | -18 LUFS | — | +| 音效 | -14 LUFS | — | +| 环境音 | -22 LUFS | highpass 60Hz + lowpass 12kHz | + +处理流程:`audio/00_raw/{type}/` → `audio/01_processed/{type}/` + +> 最终部署到 `audio/mp3/` 的文件应为 44100Hz、单声道、128kbps MP3。从 `01_processed` 拷贝/转码到 `mp3/` 时确认格式。 + +--- + +## 六、文件命名规范 + +### 配音(角色台词) + +``` +audio/mp3/tts/{role}_{scene}_{type}_wav_v1.mp3 +``` +林夏项目约定带 `_wav_v1` 后缀(历史命名,沿用)。 + +### 旁白 + +``` +audio/mp3/narrator/{game_id}/{game_id}_{scene_id}_{type}.mp3 +``` + +`{type}` 取值:`intro`(开场)/ `narrate`(描述)/ `tap`(单击旁白)/ `dt`(双击旁白)/ `ending_{id}`(结局)等。 + +### 音乐 / 音效 / 环境音 + +``` +audio/mp3/music/{name}_v1.mp3 +audio/mp3/sfx/sfx_{name}_v1.mp3 +audio/mp3/ambience/amb_{name}_v1.mp3 +``` + +--- + +## 七、生成优先级(生产顺序建议) + +按以下顺序生产,优先保证核心体验可玩: + +1. **P0(必须)**:每个游戏 `intro` + 所有 `narrate` + 所有 `tap`/`dt` 旁白 +2. **P1(重要)**:所有 `ending` 结局配音 +3. **P2(重要)**:角色主台词(NPC Round 1) +4. **P3(次要)**:角色反应台词(Round 2/3)、BGM +5. **P4(可选)**:条件分支变体(`_a`/`_b`)、环境音、叙事音效 + +> 旁白和 intro 优先级最高——它们决定玩家能否理解游戏。即使角色台词还没生成,引擎会用浏览器 TTS 兜底旁白文本,游戏也能跑起来。 + +--- + +## 八、完整生产清单(林夏重做时按此打勾) + +### 配音 +- [ ] 整理角色台词表(写入 `batch_tts_voxcpm.py` 的 `LINES`) +- [ ] 整理旁白脚本(对照 `docs/音频脚本.md`,写入 `batch_narrator_tts.py`) +- [ ] 为每个角色/旁白写音色提示词(对照 `docs/旁白音色提示词.md`) +- [ ] 启动 VoxCPM2:`bash /home/xsl/tts-server/start-voxcpm.sh` +- [ ] 生成音色样本:`python3 audio/gen_narrator_samples.py` +- [ ] 试听 + 选定音色,放入 `voice_mp3/narrator/{game}/`、`voice_mp3/{角色}/` +- [ ] 生产旁白:`python3 audio/batch_narrator_tts.py --source voice_mp3 --priority p0` +- [ ] 生产角色:`python3 audio/batch_tts_voxcpm.py --source voice_mp3` +- [ ] 归一化:`bash audio/process_audio.sh tts` + +### 音乐 +- [ ] 写 BGM caption(写入 `gen_music_acestep.py` 的 `BGM_TRACKS`) +- [ ] `conda activate acestep && python3 audio/gen_music_acestep.py --category bgm` +- [ ] 归一化:`bash audio/process_audio.sh music` + +### 音效 +- [ ] 写音效 caption(写入 `gen_sfx_acestep.py` 的 `SFX_TRACKS`) +- [ ] `conda activate acestep && python3 audio/gen_sfx_acestep.py` +- [ ] 归一化:`bash audio/process_audio.sh sfx && bash audio/process_audio.sh ambience` + +### 部署 +- [ ] 确认 `audio/mp3/` 下文件齐全、格式正确(44100Hz/单声道/128kbps MP3) +- [ ] 启动游戏验证:`python3 -m http.server 8765 --bind 0.0.0.0`,手机访问 `http://:8765/game/` + +--- + +## 九、常见问题 + +| 问题 | 对策 | +|------|------| +| TTS 服务起不来 | 看 `/tmp/voxcpm-server.log`;若降噪模型下载失败,用 `VOXCPM_ENABLE_DENOISER=0 bash /home/xsl/tts-server/start-voxcpm.sh` | +| 端口 8000 被占 | `ss -lnt \| grep 8000` 查占用;如需换端口,`VOXCPM_PORT=xxxx bash start-voxcpm.sh`,并把 `gen_narrator_samples.py` 等脚本的 `VOXCPM_PORT` 一并设 | +| 某角色音色不满意 | 重新调 `style` 提示词,重跑 `gen_narrator_samples.py`,换样本 | +| 响度不统一 | 确认走了 `process_audio.sh`,别直接用 `00_raw` 的文件 | +| 长文本 TTS 质量下降 | 分段生成,`process_audio.sh` 会自动拼接(见脚本中分段处理逻辑) | +| ACE-Step 报 cuBLAS 错 | 脚本已内置修复;若仍报错,确认 `conda activate acestep` 后再跑 | diff --git a/docs/音频脚本.md b/docs/音频脚本.md new file mode 100644 index 0000000..fdf2c94 --- /dev/null +++ b/docs/音频脚本.md @@ -0,0 +1,555 @@ +# 盲游 · 音频脚本总文档 + +> 本文档是 TTS 生成的唯一依据。所有旁白台词、情绪标注、文件命名均在此定义。 +> 代码中的 `scene.narrate` 文本与本文档保持一致。 +> 凡标注「动态,浏览器TTS」的行不需要预先生成文件。 + +--- + +## 一、技术规格 + +| 项目 | 规格 | +|------|------| +| 格式 | MP3,128kbps | +| 采样率 | 44100 Hz | +| 声道 | 单声道 | +| 响度标准 | -18 LUFS(与角色语音一致) | +| 首尾静音 | 开头 100ms,结尾 200ms | + +--- + +## 二、文件命名规范 + +``` +audio/mp3/narrator/{game_id}/{game_id}_{scene_id}_{type}.mp3 +``` + +- `{game_id}`:linxia / yinian / shouye / houzhen / yisheng / fu +- `{scene_id}`:如 msg01、s02、v03、h04、r10、f05 +- `{type}`: + - `intro` 开场白 + - `narrate` 场景描述(无条件分支) + - `narrate_a/b` 场景描述(有条件分支,a=条件成立,b=不成立) + - `tap` 单击后旁白 + - `tap_a/b` 单击后旁白(条件分支) + - `dt` 双击后旁白 + - `ending_{id}` 结局旁白 + +--- + +## 三、各游戏旁白声音设定 + +| 游戏 | 旁白定位 | 语速 | 音调 | 情感基调 | TTS 音色建议 | +|------|---------|------|------|---------|------------| +| **林夏** | 纪录片旁白——克制、观察、不评判 | 0.85 | 标准 | 平静中带一丝悲悯 | 女声,成熟,普通话标准 | +| **一念** | 内心独白——像你自己的声音 | 0.80 | 略低 | 亲密、缓慢、有重量 | 女声,接近耳语但清晰 | +| **守夜** | 对讲机预录提示——老同事留的消息 | 0.90 | 标准偏低 | 实务、疲惫、温和 | 男声,中年感,略带沙哑 | +| **候诊** | 时间本身的声音——无情感 | 0.95 | 标准 | 完全中性,精确 | 女声或男声均可,播音腔 | +| **遗声** | 手机系统语音——像 Siri 但更温 | 0.92 | 略高 | 冷静,有礼,略机械 | 女声,偏年轻,咬字清晰 | +| **浮** | 梦里的声音——几乎不存在 | 0.75 | 略低,柔 | 极轻,近乎飘渺 | 女声,气声成分多,偏空灵 | + +--- + +## 四、《林夏》旁白脚本 + +> 旁白定位:纪录片旁白。不替林夏说话,只描述发生了什么、房间里的状态。 + +### 开场 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_intro.mp3` | 今晚九点。你是林夏,二十四岁,在北京独居。手机屏幕碎了,但还能用。今晚有十六条消息,你来决定接谁,不接谁。单击接听或回复,双击拒接或忽略。 | 整个游戏唯一一次说明操作,语气平稳 | + +### 消息01 · 妈妈来电 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg01_narrate.mp3` | 晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。 | 平静陈述,稍有停顿感 | +| `linxia_msg01_tap.mp3` | 你接了。妈妈的声音从电话里传来,带着一点日常的关心,问你今天怎么样,吃了没有。你说还好。挂掉之后,房间里又安静了。 | 温和,轻描淡写 | +| `linxia_msg01_dt.mp3` | 铃声响了又停。妈妈没再打来。 | 简短,留白 | + +### 消息02 · 小美语音×3 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg02_notify.mp3` | 九点一刻。微信震了两下。小美给你发来三条语音消息。 | — | +| `linxia_msg02_notify_dt.mp3` | 你把手机屏幕朝下放着。消息还在那里,但你不想听。 | — | +| `linxia_msg02_play_narrate.mp3` | 小美说了很多,大意是关于上次那件事,她一直想跟你说清楚,她说她只是太担心你了。最后她问:你最近还好吗? | 概括式,不朗读原话 | +| `linxia_msg02_play_tap.mp3` | 你回了消息。小美很快回说:谢谢你愿意回我。 | — | +| `linxia_msg02_play_dt.mp3` | 你听完了,但没有回。消息就这样放着。 | — | + +### 消息03 · 外卖来电 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg03_narrate.mp3` | 九点二十分。来电话了,是一个陌生号码——是外卖小哥,说你的外卖到了,问你在哪层。 | — | +| `linxia_msg03_tap.mp3` | 你接了,告诉他楼层,外卖放门口就好。过了一会儿,门铃响了,外卖到了。 | — | +| `linxia_msg03_dt.mp3` | 你没接。不知道外卖最后怎么样了。 | — | + +### 消息04 · 阿哲语音 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg04_notify.mp3` | 九点半。微信震了一下。阿哲——你的前男友——给你发来一条语音。 | "前男友"三字平静说出,不带评判 | +| `linxia_msg04_notify_dt.mp3` | 你没有打开。阿哲的消息放在那里。 | — | +| `linxia_msg04_play_narrate.mp3` | 阿哲说,他有一件东西落在你这里了,问你方便不方便他过来拿一下。语气很平,不像是有别的意思。 | — | +| `linxia_msg04_play_tap.mp3` | 你回了条消息说可以。阿哲说谢谢,说到时候再联系。你放下手机,有点说不清的感觉。 | — | +| `linxia_msg04_play_dt.mp3` | 你没有回。消息就这样。 | — | + +### 消息05 · HR王姐 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg05_notify.mp3` | 九点四十五分。HR王姐发来语音,是关于下个月调岗的事,问你有没有空聊一聊。 | — | +| `linxia_msg05_notify_dt.mp3` | 你把消息标记了已读,回头再说。 | — | +| `linxia_msg05_play_narrate.mp3` | 王姐说有个岗位的变动,你可能会受到影响,想提前跟你说一声,让你有个准备。语气是友善的,像是帮你。 | — | +| `linxia_msg05_play_tap.mp3` | 你回了消息说谢谢告知,改天约时间聊。王姐说好的。 | — | +| `linxia_msg05_play_dt.mp3` | 你没有回。这件事先放一放。 | — | + +### 消息06 · 安安邀约 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg06_notify.mp3` | 晚上十点。安安发来语音,她说她们今晚在酒吧,问你要不要过来,说来了就是了,不用提前说。 | — | +| `linxia_msg06_notify_dt.mp3` | 你没有打开消息。今晚不想动。 | — | +| `linxia_msg06_play_narrate.mp3` | 安安的声音很热闹,背景里有音乐。她说来嘛来嘛,好久没见了,就几个人,很轻松的那种。 | — | +| `linxia_msg06_play_tap.mp3` | 你回了条消息说行,等等到。安安说耶!你站起来换了件外套,但后来不知道为什么又坐下了。 | 后半句稍慢,有一点停顿 | +| `linxia_msg06_play_dt.mp3` | 你回了消息说今晚状态不太好,下次吧。安安说没事,下次一定。 | — | + +### 消息07 · 寝室群×5 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg07_notify.mp3` | 晚上十点一刻。寝室群活了,连着震了好几下,是大学室友们在聊。你们已经很久没联系了。 | — | +| `linxia_msg07_notify_dt.mp3` | 你把群消息静音了。不是今晚。 | — | +| `linxia_msg07_play_narrate.mp3` | 她们在聊上周聚会的事,有人说想你了,有人问你最近怎么样,有人说快来啊。那种熟悉的语气,你有点想念。 | — | +| `linxia_msg07_play_tap.mp3` | 你发了一条消息,说最近还好,想你们了。群里一下子热闹起来,大家说你终于出现了。你看着那些消息,嘴角动了一下。 | 结尾"嘴角动了一下"轻描淡写 | +| `linxia_msg07_play_dt.mp3` | 你看完了,但没有回复。你把手机放下。那种热闹是她们的,你在这边。 | — | + +### 消息08 · 未知号码 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg08_narrate.mp3` | 晚上十点半。手机响了,来电显示:未知号码。铃声有点不一样,低沉的那种。 | "低沉"略重 | +| `linxia_msg08_tap.mp3` | 你接了。里面没有声音,只有轻微的呼吸声,然后断了。你不知道是谁,心跳加快了一下。 | 节奏略慢,有悬疑感 | +| `linxia_msg08_dt.mp3` | 未知号码,不接。铃声停了。 | 简短 | + +### 消息09 · 小美醉酒×4 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg09_notify.mp3` | 十点四十五分。小美又发来消息,连续四条。语音很长,而且发送时间是一条接一条。 | 语速可略快,传递焦虑感 | +| `linxia_msg09_notify_dt.mp3` | 消息放那里,你没有打开。 | — | +| `linxia_msg09_play_narrate.mp3` | 小美喝多了,说话有点不清楚,但每一句都很认真。她说她一直有件事想跟你说:那年她告诉了别人,是她不对,她知道,她一直很愧疚,她说她怕你恨她。 | 这段要有重量,不急 | +| `linxia_msg09_play_tap.mp3` | 你打了几个字发过去。小美隔了很久才回,说谢谢你,然后说她要先睡了,明天聊。你把手机放下。 | — | +| `linxia_msg09_play_dt.mp3` | 你没有回。这件事太重了,不是今晚能说清楚的。 | — | + +### 消息10 · 妈妈文字 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg10_narrate.mp3` | 深夜十一点刚过。妈妈发来一条文字消息,就五个字:睡了吗,夏夏。 | "夏夏"两字稍软 | +| `linxia_msg10_tap.mp3` | 你回了:还没呢,妈你睡吧。妈妈说好,早点睡,然后发了个晚安的表情。你盯着那个表情看了一会儿。 | — | +| `linxia_msg10_dt.mp3` | 你没有回妈妈的消息。她那边大概以为你已经睡了。 | — | + +### 消息11 · 阿哲关键来电 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg11_narrate.mp3` | 十一点一刻。手机响了,专属铃声——是阿哲打来的。 | — | +| `linxia_msg11_tap.mp3` | 阿哲说他就在附近,问你方不方便他上来取一下那件东西。他的声音平静,但停顿了一下。 | — | +| `linxia_msg11_dt.mp3` | 铃声停了。阿哲没再打来。 | — | +| `linxia_msg11_choice_narrate.mp3` | 他在等你说行不行。 | 非常短,有留白 | +| `linxia_msg11_choice_tap.mp3` | 你说好,上来吧。阿哲说谢谢,说马上到。你放下手机,在房间里站了一会儿。 | — | +| `linxia_msg11_choice_dt.mp3` | 你说今晚不太方便。阿哲沉默了一秒,说行,那之后再说。挂掉了。 | "沉默了一秒"前后要有停顿 | + +### 消息12 · 周南初次语音 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg12_notify.mp3` | 十一点半。一个名字出现在屏幕上:周南。你有三年没联系了,他突然发来一条语音。 | — | +| `linxia_msg12_notify_dt.mp3` | 你没有打开周南的消息。三年了,不急这一会儿。 | — | +| `linxia_msg12_play_narrate.mp3` | 周南说:林夏你好,是我周南,不知道你还记不记得,就是突然想起你,也不知道你还在不在这个号,就发一条。他的声音还是老样子,只是低沉了一些。 | 转述口吻,温和 | +| `linxia_msg12_play_tap.mp3` | 你回了:记得,你好啊。消息发出去了,你盯着那两个字,觉得说多说少都不对,就这样。 | — | +| `linxia_msg12_play_dt.mp3` | 你听完了,没有回。也许三年的空白不是一条消息能填上的。 | — | + +### 消息13 · 周南长语音×4 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg13_notify.mp3` | 深夜十一点四十五分。周南又发来四条,比刚才长很多。 | — | +| `linxia_msg13_notify_dt.mp3` | 你把手机放到一边了。今晚已经够多了。 | — | +| `linxia_msg13_play_narrate.mp3` | 周南说了他这十年:换了几个城市,做了几份工作,有一段感情,然后结束了。他说他今晚喝了一点酒,就想到你,想到你们高中的那些事,说他一直不知道当年为什么就断了联系。然后他问:你在北京过得好吗? | 这段较长,语速可略缓 | +| `linxia_msg13_play_tap.mp3` | 你想了很久,然后把这段时间的事告诉了周南。说不上好不好,就是在过。他说谢谢你告诉我这些。你们聊了很久。 | — | +| `linxia_msg13_play_dt.mp3` | 你回了说还好,然后问了他现在在哪里。你们就这样聊着,没有太深入,但很平静。 | — | + +### 消息14 · 妈妈第二次来电 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg14_narrate.mp3` | 凌晨零点一刻。妈妈又打来电话了,这么晚——她还没睡。 | "这么晚"后有停顿 | +| `linxia_msg14_tap_a.mp3` | 你接了。妈妈说:我就是想再听到你的声音。你们聊了一会儿,说了一些很日常的话,但你都记住了。 | 条件:mom_link≥1 | +| `linxia_msg14_tap_b.mp3` | 你接了。妈妈说:没事,就是问问你睡了没。你说没呢,妈你睡吧。她说想听你的声音。 | 条件:mom_link=0 | +| `linxia_msg14_dt.mp3` | 太晚了,挂掉了。妈妈没有再打来。 | — | + +### 消息15 · 时光胶囊 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg15_narrate.mp3` | 凌晨零点半。手机弹出一个系统通知:三年前的今天,你给三年后的自己录了一条语音,今天到了。 | — | +| `linxia_msg15_tap.mp3` | 你听到了三年前的自己的声音。她说:如果你在听这条消息,说明你撑过来了。不管现在怎么样,你撑过来了。然后她停顿了很久,说:辛苦了。 | 最后"辛苦了"三字要有分量,停顿后说 | +| `linxia_msg15_dt.mp3` | 你把那个通知划掉了。也许不是现在。 | — | + +### 消息16 · 结局触发点 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_msg16_narrate_a.mp3` | 凌晨两点。妈妈发来一段很长的语音,她说她一直有些话想说给你听。 | 条件:mom_link≥2 | +| `linxia_msg16_narrate_b.mp3` | 凌晨两点。房间很安静,窗外偶尔有车声。今晚就这样了。 | 条件:mom_link<2 | +| `linxia_msg16_tap_a.mp3` | 妈妈说了很多——关于你小时候,关于她一直担心的事,关于她说不出口但一直想说的话。你把手机贴在耳边,听完了。 | 条件:mom_link≥2 | +| `linxia_msg16_tap_b.mp3` | 你就这样坐着,今晚的声音慢慢变少了。 | 条件:mom_link<2 | +| `linxia_msg16_dt.mp3` | 你把手机放到床头,闭上眼睛。今晚就这样了。 | — | + +### 结局 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `linxia_ending_return.mp3` | 《回归》。阿哲来了,或者你们说好了见面。不管那个门开不开,今晚你做了一个决定。 | — | +| `linxia_ending_link.mp3` | 《连接》。今晚你和妈妈说了话,不止一次。那种联系很细,但是在。也许明天你会想打电话回去。 | — | +| `linxia_ending_past.mp3` | 《旧路》。周南在深夜找到了你,你们聊了很久。那条从高中就断掉的线,今晚接上了一点点。 | — | +| `linxia_ending_self.mp3` | 《自省》。今晚你听了三年前自己的声音。她说辛苦了,你接受了这句话。这不算小事。 | — | +| `linxia_ending_island.mp3` | 《孤岛》。今晚大部分的消息,你都没有回应。你一个人度过了这个夜晚。这也是一种选择,你心里清楚。 | — | + +--- + +## 五、《一念》旁白脚本 + +> 旁白定位:你自己的内心独白。不是外部叙述者,是你在黑暗中自己的声音,和七个声音对话。 + +### 开场 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `yinian_intro.mp3` | 你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。七个声音在等着和你说话。单击,让它说完。双击,让它走。 | 慢,像从睡梦中说话 | + +### 7个声音 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `yinian_v01_narrate.mp3` | 第一个声音来了。是三年前的你。那时候你刚做了一个选择,你觉得那是对的。那时候的你说:我们当时想要的,你还记得吗? | — | +| `yinian_v01_tap.mp3` | 你让三年前的自己说完了。她说,你当时很想要那件事。你知道那是真的。 | — | +| `yinian_v01_dt.mp3` | 三年前的声音淡去了。你选择了不看它。 | — | +| `yinian_v02_narrate.mp3` | 第二个声音。是一个你很久没联系的朋友,她留了条语音。她说:你最近怎么样,我有点担心你。很久了,你一直没回。 | — | +| `yinian_v02_tap.mp3` | 你把这份担心收进来了。她还在关心你,这件事是真实的。 | — | +| `yinian_v02_dt.mp3` | 留言消失了。你没有听完。 | — | +| `yinian_v03_narrate.mp3` | 第三个声音。是关于钱的恐惧。它说:你知道你现在能撑多久吗?如果这件事不成,你怎么办?它问得很具体,很冷静。 | 这段可以稍冷、稍急 | +| `yinian_v03_tap.mp3` | 你承认了:这份恐惧是真实的。你带着它,而不是假装它不存在。 | — | +| `yinian_v03_dt.mp3` | 恐惧的声音淡下去了。你知道它还在,但今晚先不看。 | — | +| `yinian_v04_narrate.mp3` | 第四个声音。是一种渴望——想要被人理解,想要有人在身边,想要不那么孤独。它来得轻,但是很深。 | — | +| `yinian_v04_tap.mp3` | 你承认了这份渴望。它是真实的,不是软弱。 | — | +| `yinian_v04_dt.mp3` | 渴望漂走了。你告诉自己,一个人也没什么。 | — | +| `yinian_v05_narrate.mp3` | 第五个声音。是一个你一直没有说出口的道歉。你知道是对谁的,你知道说的是什么。这个声音只是在等你。 | 停顿感,等待感 | +| `yinian_v05_tap.mp3` | 你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。 | "松开了"要有呼吸感 | +| `yinian_v05_dt.mp3` | 道歉的声音还在那里。你没有说。它等着。 | — | +| `yinian_v06_narrate.mp3` | 第六个声音。是今晚那个决定本身。它把两种可能都摆在你面前,不说话,只是等着你看清楚。 | — | +| `yinian_v06_tap.mp3` | 你看了。两条路都有代价,你知道了。这不让选择变得更容易,但让它变得更真实。 | — | +| `yinian_v06_dt.mp3` | 你把眼睛闭上了。还没准备好看那两条路。 | — | +| `yinian_v07_narrate_a.mp3` | 第七个声音,是现在的你。她说:你今晚接住了很多,你没有回避它们。这需要力气。 | 条件:accepted≥5 | +| `yinian_v07_narrate_b.mp3` | 第七个声音,是现在的你。她沉默了很久,然后说:你把它们都推走了。也许你需要的不是答案,是喘息。 | 条件:accepted≤1 | +| `yinian_v07_narrate_c.mp3` | 第七个声音,是现在的你。她说:你把恐惧留下了,但把渴望推走了。你知道这意味着什么吗? | 条件:kept_fear&&!kept_longing | +| `yinian_v07_narrate_d.mp3` | 第七个声音,是现在的你。她说:你接住了一些,放走了一些。这就是你今晚的状态。 | 默认情况 | +| `yinian_v07_tap.mp3` | 你听完了现在的自己说话。她说:不管你今晚做什么决定,你都已经在这里待过了。这算数。 | — | +| `yinian_v07_dt.mp3` | 你没有听完。你觉得你已经知道了你需要知道的。 | — | + +### 结局 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `yinian_ending_accept.mp3` | 《接纳》。你今晚接住了很多,你没有选择只是轻装。也许你的决定,就是带着这些往前走。 | — | +| `yinian_ending_release.mp3` | 《出发》。你今晚把大部分都放走了。你想要的也许是轻。轻装,也是一种勇气。 | — | +| `yinian_ending_stuck.mp3` | 《困住》。你接受了恐惧,但推走了渴望。你知道风险,但忘了你想要的是什么。这个决定,也许需要再等一等。 | — | +| `yinian_ending_reconcile.mp3` | 《和解》。你说出了那声道歉。不管今晚的决定是什么,你和自己和解了一点点。这很重要。 | — | +| `yinian_ending_between.mp3` | 《悬在中间》。你接住了一些,放走了一些。你还悬在两个答案之间。也许这就是你今晚真实的状态。 | — | + +--- + +## 六、《守夜》旁白脚本 + +> 旁白定位:老同事提前录好的对讲机提示。语气像一个见过很多夜班的人在告诉你怎么判断情况。实务、不煽情。 + +### 开场 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `shouye_intro.mp3` | 凌晨零点。你是这栋楼的夜班保安,独自坐在一楼监控室。你不能离开座位,但你有一部对讲机和一部电话。今晚五点交班。单击,去查那个声音。双击,按兵不动。 | — | + +### 8个事件 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `shouye_s01_narrate.mp3` | 凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。 | — | +| `shouye_s01_tap.mp3` | 你拨了地下室的内线。没人接。你下去看了一眼,是一根管子在漏水,用旧布堵住了。回到监控室,零点二十三分。 | 实务口吻,记录感 | +| `shouye_s01_dt.mp3` | 你听了一会儿。应该不碍事。你把注意力放回到监控屏幕上。 | — | +| `shouye_s02_narrate.mp3` | 零点四十七分。四楼传来争吵声,一男一女,断断续续,能听出来语气很激动,但听不清说什么。 | — | +| `shouye_s02_tap.mp3` | 你拨了四楼的号码。男的接了,说没事,就是聊天,让你别担心。声音还是有点紧,但他们关掉了。你记下来了:四楼,零点五十二分,双方均在场。 | — | +| `shouye_s02_dt.mp3` | 家务事,你管不着。你转过身去继续盯着屏幕。争吵后来停了,你不知道是什么时候。 | — | +| `shouye_s03_narrate.mp3` | 凌晨一点十七分。三楼有脚步声,很轻,走走停停。监控记录:三楼只有七零二亮着灯,住户是老陈,八十岁,独居。 | — | +| `shouye_s03_tap.mp3` | 电话通了,老陈说他在找眼镜,眼镜掉床底下了。你说没事我就是例行确认一下。他说谢谢你小伙子,然后你们聊了几句,关于他年轻时候也做过工程。你挂掉电话,一点二十八分。 | 这段可以稍温暖 | +| `shouye_s03_dt.mp3` | 老人家睡不好,很正常。你记了一笔,继续监控。脚步声后来停了。 | — | +| `shouye_s04_narrate.mp3` | 凌晨两点零三分。大门门铃响了,不是访客系统,是有人在外面按了手动铃,按了两下,然后停了。没有人应答。监控画面:门口没有人了。 | — | +| `shouye_s04_tap.mp3` | 你走到门口,什么都没有,只有一个快递包裹靠在墙边,大概是外卖或者什么。也许是送错了。你把包裹推到角落,拍了张照,两点一刻回到座位。 | — | +| `shouye_s04_dt.mp3` | 无应答,无入侵,记录在案,按流程忽略。你回到屏幕前。 | — | +| `shouye_s05_narrate_a.mp3` | 凌晨两点五十五分。你不知道从什么时候开始,眼皮变得很重……你睡着了一会儿。醒来的时候,你看到监控里,四楼门口放着一个箱子,不知道是谁的,不知道什么时候放的。 | 条件:missed_fight=true | +| `shouye_s05_narrate_b.mp3` | 凌晨两点五十五分。你不知道从什么时候开始,眼皮变得很重……你睡着了一会儿。醒来的时候,监控里一切正常。你记录:三点十二分,短暂失守。 | 条件:missed_fight=false | +| `shouye_s06_narrate.mp3` | 凌晨三点半。你的私人手机响了。是家里的人打来的。这么晚打来——可能是什么事,也可能只是弄错时间了。 | — | +| `shouye_s06_tap.mp3` | 是你妈,她说睡不着,看见你今天发的朋友圈,就想打个电话。你们聊了十分钟,说了些不重要的事。挂掉之后,监控室比之前暖了一点点。 | 结尾"暖了一点点"轻说 | +| `shouye_s06_dt.mp3` | 这么晚不方便。你把手机调成静音,准备待会儿发条消息。你没有发。 | — | +| `shouye_s07_narrate.mp3` | 凌晨四点十二分。屋顶传来动静,断断续续,不像是机械设备,更像是有什么东西在走动。 | — | +| `shouye_s07_tap.mp3` | 你拿着手电上了顶层,推开屋顶的铁门,风吹过来,你看见了天边刚刚开始发白的颜色——还不是黎明,但是快了。然后你看见了一只流浪猫,坐在水箱上看着你。你们看了对方一会儿,然后你下来了。你是今晚唯一上了屋顶的人。 | 这段稍慢,有空间感 | +| `shouye_s07_dt.mp3` | 你记了一笔,继续监控。动静后来停了。 | — | +| `shouye_s08_narrate.mp3` | 五点整。接班的人进来了,你们交接了几句,递了记录本,你背上包走出去。外面已经有一点点亮了。 | — | + +### 结局 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `shouye_ending_dawn.mp3` | 《你看到了什么》。今晚你一个人上了屋顶,你看到了那只猫,你看到了天边开始变色。只有你看到了。这算不上什么,但只有你知道。 | — | +| `shouye_ending_solid.mp3` | 《平凡守夜》。你今晚查了应该查的,接了应该接的电话。没有大事,没有遗漏。这栋楼平安了一夜,有一部分是因为你在。 | — | +| `shouye_ending_detached.mp3` | 《这不是我的事》。你今晚保持了距离。声音来了,你记录,你不动。你守住了一夜,但不知道楼里发生了什么。这也是一种守夜的方式。 | — | +| `shouye_ending_oversight.mp3` | 《疏忽》。你今晚漏掉了四楼的事。那个女人在凌晨两点多拎着箱子离开了,你没有注意到。也许和你没有关系。也许有。你不知道。 | — | +| `shouye_ending_ordinary.mp3` | 《普通的夜班》。你做了你能做的,漏掉了一些,照顾到了一些。和所有普通的夜班一样。 | — | + +--- + +## 七、《候诊》旁白脚本 + +> 旁白定位:时间本身的声音。无任何情感,只陈述事实。播音腔,精确到分钟。 + +### 开场 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `houzhen_intro.mp3` | 下午一点半,你坐在医院候诊室,等一个检查结果。叫号机已经叫到了八十四号。你是九十四号。单击,把注意力放到外面。双击,退回到自己里面。 | — | + +### 9个时间切片 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `houzhen_h01_narrate.mp3` | 叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。 | — | +| `houzhen_h02_narrate.mp3` | 你左边坐着一个老人。他在看窗外,窗外是停车场,没有什么特别的。但他看得很专注。 | — | +| `houzhen_h02_tap.mp3` | 你看了他一会儿。他感觉到了,朝你点了点头。你也点了点头。没有说话,但有什么东西发生了。 | — | +| `houzhen_h02_dt.mp3` | 你低下头,看手机。屏幕亮着,什么都有,但你没有真的看进去什么。 | — | +| `houzhen_h03_wait.mp3` | 叫号机叫到了八十八号。 | 极短 | +| `houzhen_h03_narrate.mp3` | 你手机上有一条两天前的消息,你当时看到了,但没有回。那个人说:最近怎么样。 | — | +| `houzhen_h03_tap.mp3` | 你打开来,打了几个字。发出去了。不知道他会不会在意这么晚才回。 | — | +| `houzhen_h03_dt.mp3` | 你把手机翻过去,屏幕朝下放在大腿上。等等再说。 | — | +| `houzhen_h04_wait.mp3` | 护士从走廊那头走过来,叫了一个名字,不是你。 | — | +| `houzhen_h04_narrate.mp3` | 对面有个小孩在哭,大约三四岁,蹲在椅子旁边哭。旁边的大人在轻声说什么,但他还是哭。 | — | +| `houzhen_h04_tap.mp3` | 你看着那个孩子。他哭了一会儿就停了,大概是哭够了。你们没有交流,但你陪着他哭完了。 | — | +| `houzhen_h04_dt.mp3` | 你把视线移开了。每个人都有自己的事,你也有你的。 | — | +| `houzhen_h05_wait.mp3` | 叫号机叫到了九十号。还有四个人。 | — | +| `houzhen_h05_narrate.mp3` | 窗外的光线变了。下午的阳光斜进来,在地板上画了一块长方形的亮。你注意到了。 | — | +| `houzhen_h05_tap.mp3` | 你盯着那块光看了一会儿。它在慢慢移动,非常慢,但是在动。时间在过。 | — | +| `houzhen_h05_dt.mp3` | 你闭上眼睛,想了想别的事。 | — | +| `houzhen_h06_wait.mp3` | 叫号机叫到了九十一号。 | — | +| `houzhen_h06_narrate.mp3` | 你的手机响了。是一个你不常打电话的人——是你家里的人。也许只是普通的问候。 | — | +| `houzhen_h06_tap.mp3` | 你接了。对方说:你在哪啊,吃了吗。你说在外面,没事。就这样。挂掉之后候诊室里还是那么安静。 | — | +| `houzhen_h06_dt.mp3` | 你看着屏幕亮了又暗。现在不是时候。等会儿再说。 | — | +| `houzhen_h07_wait.mp3` | 叫号机叫到了九十二号。 | — | +| `houzhen_h07_narrate.mp3` | 你右边的人一直在看同一页的杂志,翻来翻去都是那页。你不确定她在看,还是只是拿着。 | — | +| `houzhen_h07_tap.mp3` | 你朝她看了一眼。你们的眼神碰了一下,然后都移开了。没有什么,就是两个陌生人在等待。 | — | +| `houzhen_h07_dt.mp3` | 你没有看。你在想等会儿检查结果会说什么。 | — | +| `houzhen_h08_narrate.mp3` | 九十三号。就差一个了。 | — | +| `houzhen_h09_narrate.mp3` | 九十四号。是你。你站起来,整理了一下衣服,走向那扇门。 | — | +| `houzhen_h09_tap.mp3` | 你推开门走进去了。门关上了。等待结束了。 | — | +| `houzhen_h09_dt.mp3` | 你停了一秒,深呼吸,然后走进去了。门关上了。 | — | + +### 结局(动态,建议浏览器TTS) + +> 候诊的结局根据统计数据动态生成,文本每次都不同,**建议保留浏览器TTS**,或预录所有组合(共约8种)。 + +若要预录所有组合,按以下模板生成: + +``` +你等了很久。 + 在这段时间里,你向外走了N次——看向别人,接了电话,留意窗外。 (N=1~5) + / 你向内走了N次——退回到自己里,先不管别的。 (N=1~5) + / 你向外走了X次,也向内走了Y次,差不多一半一半。 (X=Y) +你记住了:[事件列表]。 +这就是你今天等待的方式。 +``` + +--- + +## 八、《遗声》旁白脚本 + +> 旁白定位:手机系统语音,像 Siri。每条录音用元数据(日期、时长、文件名)介绍,然后玩家决定听不听。 + +### 开场 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `yisheng_intro.mp3` | 你收到了一部手机。手机的主人去世了,一个月前。他留下了很多录音,按时间排列。你不认识他。或者你以为你不认识他。单击,听这条录音。双击,跳过,永远不知道这条说了什么。 | — | + +### 15条录音的元数据播报 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `yisheng_r01_narrate.mp3` | 第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。 | 系统感,读出元数据 | +| `yisheng_r02_narrate.mp3` | 第二条录音。三月十二日,下午。时长:三十八秒。文件名:发工资了。 | — | +| `yisheng_r03_narrate.mp3` | 第三条录音。三月十八日,晚上。时长:一分零七秒。文件名:说说工作的事。 | — | +| `yisheng_r04_narrate.mp3` | 第四条录音。四月二日。时长:四十九秒。文件名:吵了一架。 | — | +| `yisheng_r05_narrate.mp3` | 第五条录音。四月十五日,深夜。时长:二十六秒。文件名:电影。 | — | +| `yisheng_r06_narrate.mp3` | 第六条录音。五月三日。时长:一分二十秒。文件名:小林的事。 | — | +| `yisheng_r07_narrate.mp3` | 第七条录音。五月二十日。时长:三十三秒。文件名:小林说的。 | — | +| `yisheng_r08_narrate.mp3` | 第八条录音。六月八日,凌晨。时长:两分十四秒。文件名:结束了。 | — | +| `yisheng_r09_narrate.mp3` | 第九条录音。六月二十日。时长:一分五十秒。文件名:在反思。 | — | +| `yisheng_r10_narrate.mp3` | 第十条录音。七月四日。时长:四十二秒。文件名:有人问起你。 | — | +| `yisheng_r11_narrate.mp3` | 第十一条录音。七月十一日。时长:一分零八秒。文件名:我查了一下。 | — | +| `yisheng_r12_narrate.mp3` | 第十二条录音。八月二日。时长:五十七秒。文件名:给不认识我的人(草稿)。 | — | +| `yisheng_r13_narrate.mp3` | 第十三条录音。九月一日。时长:两分三十八秒。文件名:我有一件事想说。 | — | +| `yisheng_r14_narrate.mp3` | 第十四条录音。十月十五日,录制时间是他去世前三天。时长:三分二十秒。文件名:最后一条,我想讲完整。 | — | +| `yisheng_r15_narrate.mp3` | 还有一条录音,没有标日期。文件名:给不认识我的人(正式版)。这条需要你听了很多条才能解锁。你解锁了。 | — | + +### 听完后的旁白(转述录音内容) + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `yisheng_r01_tap.mp3` | 他说:今天买了一袋橙子,是脐橙,比上次的甜很多。建议大家有机会可以试试。就这些。录音结束。 | — | +| `yisheng_r02_tap.mp3` | 他说:今天发工资了。然后列了他这个月的支出清单,很详细——房租、电费、手机月供、还有给他妈买了一件外套。他说最后剩了不多,但够。录音结束。 | — | +| `yisheng_r03_tap.mp3` | 他说他们组里来了个新同事,总是在会议上抢着发言,说了很多他本来准备说的话。他说他当时没说话,他不确定那是不是对的选择。他在录音里问自己:我是懦弱了,还是留有余地?他没有答案。录音结束。 | — | +| `yisheng_r04_tap.mp3` | 他说他今天和那个新同事正面冲突了,是在走廊,两个人都没控制住。他说他说了一句话,事后觉得说得太重了。他在录音里把那句话又说了一遍,想听听是不是真的太重。录音里他沉默了一会儿,然后说:是的,是太重了。录音结束。 | — | +| `yisheng_r05_tap.mp3` | 他说他去看了一部很老的电影,黑白的,在一个小影院,观众只有六个人。他说他中途哭了,但他不确定是因为电影,还是因为电影院里那种空荡荡的感觉。录音结束。 | — | +| `yisheng_r06_tap.mp3` | 他开始讲一个叫小林的朋友。他说小林是他认识最久的人,从小一起长大的,后来各自去了不同的城市,联系越来越少。他说他最近一直在想,那种联系的稀薄,是谁的责任?还是说这就是长大的代价?录音结束。 | — | +| `yisheng_r07_tap.mp3` | 他说小林打电话来了。小林在电话里说了一句话,让他很难受。小林说:感觉你现在不太一样了,变得……不知道,挺难接近的。他说他当时说了没什么,你想多了。他说他那时候应该说:我只是最近状态不好。但他没说。录音结束。 | — | +| `yisheng_r08_tap.mp3` | 他说一段感情结束了,他没有细说是谁,说了有多久,只说:结束了,是我提的,我觉得这是对的,但今晚我睡不着。他说他在厨房坐着,泡了杯茶,但茶凉了也没喝。他说:我只是想把今晚的感觉记下来,不想让它消失。录音结束。 | — | +| `yisheng_r09_tap.mp3` | 他说他一直在想那段感情里他做错了什么。他列了很多条,每一条都很具体。然后他说:但是我说不清,这些错是因为我是这样的人,还是因为那时候我在经历别的事情,所以才这样。他说:这个问题很重要,但我没有答案。录音结束。 | — | +| `yisheng_r10_tap.mp3` | 他说:今天有人跟我提到了你的名字。我以为我早就忘了,但我发现我还记得很多细节。他没有解释"你"是谁。他说:我觉得我应该把这件事录下来,因为我发现我不想忘记。录音结束。 | — | +| `yisheng_r11_tap.mp3` | 他说:我查了你现在在哪里,在做什么。不是恶意,就是想知道。他说他找到了一些信息,知道了你现在的大致状态。他说:你好像过得还不错,我很高兴,也有一点……他停了,然后说:没什么。录音结束。 | — | +| `yisheng_r12_tap.mp3` | 他说:如果你听到这条,你肯定不认识我。我在想,把这些录音留给一个陌生人,有什么意义。然后他说:也许是因为我希望有人知道我活过,而不是认识我的人,而是真正陌生的人——你不需要对我有感情,你只是听了。录音结束。 | — | +| `yisheng_r13_tap.mp3` | 他深吸一口气,说:有一件事我从没对任何人说过。他停了很久,然后说:我有时候觉得活着的意义我不太找得到,不是说我要做什么,只是我不知道我在这里有什么用。他说:我知道这句话很沉,但我必须把它说出来,因为放在心里太久了。然后他说:我今天状态还可以,就是今天有这个力气把它说出来。录音结束。 | 这段台词有情绪重量,需要留白 | +| `yisheng_r14_tap.mp3` | 他说:我知道这是最后一条了。我不是悲观,我只是知道。他说:我想对听这些录音的人说,我不后悔我活过的大多数时候。橙子是甜的,老电影是值得哭的,小林的声音我还记得,那个人,不管怎样,我不后悔爱过。他说:如果你听了很多条,谢谢你。你是陌生人,但你陪着我说完了。录音结束。 | — | +| `yisheng_r15_tap.mp3` | 他说:你好。你不认识我,我也不认识你。但你坐在那里,听完了我这些年说的话。我不知道你是谁,我不知道你们那边现在是白天还是夜晚。我只是想说:谢谢你愿意听一个陌生人说话。这很重要,比你知道的还要重要。录音结束。这是最后一条了。 | — | + +### 跳过时的旁白 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `yisheng_skip_generic.mp3` | 你跳过了这条。你永远不会知道这条说了什么了。 | 第1条用,带说明 | +| `yisheng_skip_short.mp3` | 你跳过了。 | 第2-9条用,简短 | +| `yisheng_skip_last.mp3` | 你跳过了第十四条,也就是最后一条。手机里没有更多录音了。 | 第14条跳过专用 | +| `yisheng_skip_r15.mp3` | 你连最后这条也跳过了。 | 第15条跳过专用 | + +### 结局 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `yisheng_ending_known.mp3` | 《我认识你》。你听完了他大部分的录音,你听到了那条隐藏的最后一条。你不认识他,但你陪他把该说的都说完了。这已经足够了。 | — | +| `yisheng_ending_stranger.mp3` | 《陌生人》。你跳过了所有关于他和你之间联系的录音。他对你来说,还是一个完全的陌生人。也许这样也好。 | — | +| `yisheng_ending_passing.mp3` | 《路过》。你路过了他的大部分录音,只停留了几条。你知道他存在过,但你不知道他是谁。 | — | +| `yisheng_ending_outline.mp3` | 《轮廓》。你听了他生活的碎片,知道了一个大致的形状。你不知道他所有的事,但你知道了一个人活着的样子。 | — | +| `yisheng_ending_brief.mp3` | 《简短的相遇》。你听了几条,然后停下了。也许你听到了你需要听的,也许你还没准备好听更多。这也是一种结果。 | — | + +--- + +## 九、《浮》旁白脚本 + +> 旁白定位:梦里的声音。几乎不存在。每个句子都像是从很远的地方说来的,有点延迟。 + +### 开场 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `fu_intro.mp3` | 你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。单击,把手伸过去。双击,看着它走。 | 极慢,像说给将睡未睡的人听 | + +### 12个漂流物 + +| 文件名 | 台词 | 备注 | +|--------|------|------| +| `fu_f01_narrate.mp3` | 第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。 | — | +| `fu_f01_tap.mp3` | 它停在你手心里,有点暖。 | 短,轻 | +| `fu_f01_dt.mp3` | 你看着它慢慢飘走了。 | — | +| `fu_f02_narrate.mp3` | 第二个。某人的笑声。你认识这个笑,但你说不清是谁的。 | — | +| `fu_f02_tap.mp3` | 笑声停在你耳边,还在回响。 | — | +| `fu_f02_dt.mp3` | 笑声远了。 | 极短 | +| `fu_f03_narrate.mp3` | 第三个。一种说不清楚的遗憾。它没有形状,但是很重。 | — | +| `fu_f03_tap.mp3` | 你把遗憾抱在怀里了。它很重,但你没有放开。 | — | +| `fu_f03_dt.mp3` | 遗憾漂过去了。你呼了口气。 | — | +| `fu_f04_narrate.mp3` | 第四个。很久没有过的安全感。轻轻的,像一床旧被子的气味。 | — | +| `fu_f04_tap.mp3` | 你把它裹在身上了。 | — | +| `fu_f04_dt.mp3` | 安全感飘走了。你看着它走。 | — | +| `fu_f05_narrate.mp3` | 第五个。想哭但没哭出来的那种感觉。像喉咙里卡着什么。 | — | +| `fu_f05_tap.mp3` | 你承认了。那个东西松动了一点点。 | — | +| `fu_f05_dt.mp3` | 它漂走了。喉咙还是有点紧。 | — | +| `fu_f06_narrate.mp3` | 第六个。一首旋律,只有开头几个音,后面的你想不起来了。 | — | +| `fu_f06_tap.mp3` | 你在心里哼了那几个音。后面的还是想不起来,但这几个音够了。 | — | +| `fu_f06_dt.mp3` | 旋律消失了。 | — | +| `fu_f07_narrate.mp3` | 第七个。被需要的感觉。有人曾对你说过你很重要,你还记得那句话。 | — | +| `fu_f07_tap.mp3` | 你把那句话放进口袋里了。 | — | +| `fu_f07_dt.mp3` | 那句话漂走了。 | — | +| `fu_f08_narrate.mp3` | 第八个。一个人的时候的自在。没人看着,没人要你做什么,只是你自己。 | — | +| `fu_f08_tap.mp3` | 你把这份自在收进来了。 | — | +| `fu_f08_dt.mp3` | 它漂走了。也许你现在不需要。 | — | +| `fu_f09_narrate.mp3` | 第九个。害怕。不知道怕什么,就是有点怕。 | — | +| `fu_f09_tap.mp3` | 你承认了:我有点害怕。说出来之后,它小了一点。 | — | +| `fu_f09_dt.mp3` | 你没有看它,害怕漂走了。也许。 | — | +| `fu_f10_narrate.mp3` | 第十个。好奇心。对某件事、某个人、某个地方,你还有好奇。 | — | +| `fu_f10_tap.mp3` | 好奇心跳进你手里了,很活跃。 | — | +| `fu_f10_dt.mp3` | 好奇心漂走了,也许它去找别人了。 | — | +| `fu_f11_narrate.mp3` | 第十一个。疲惫。真实的、身体里的疲惫,它已经跟了你很久了。 | — | +| `fu_f11_tap.mp3` | 你说:我累了。疲惫点了点头,说我知道。 | — | +| `fu_f11_dt.mp3` | 疲惫漂走了。你感觉稍微轻了一点点。 | — | +| `fu_f12_narrate.mp3` | 最后一个。它没有名字。是你漂浮在这里时特有的某种感觉,说不清,但它在。 | 比前面更慢 | +| `fu_f12_tap.mp3` | 你把它揣在怀里了,连同它的没有名字。 | — | +| `fu_f12_dt.mp3` | 你看着它留在了这片水里,这片梦里。 | — | + +### 结局(动态,建议浏览器TTS或预录组合) + +> 浮的结局文本完全动态(读出玩家保留/放走的清单),共约 2^12=4096 种组合,**建议保留浏览器TTS**。 +> +> 若要预录,只需录以下固定句子,运行时拼接: + +| 文件名 | 台词 | +|--------|------| +| `fu_ending_open.mp3` | 你漂回来了。 | +| `fu_ending_kept_prefix.mp3` | 你带走了: | +| `fu_ending_released_prefix.mp3` | 你放走了: | +| `fu_ending_close.mp3` | 这就是今天的你。 | +| `fu_item_wind.mp3` | 夏天的风 | +| `fu_item_laugh.mp3` | 笑声 | +| `fu_item_regret.mp3` | 遗憾 | +| `fu_item_safe.mp3` | 安全感 | +| `fu_item_tears.mp3` | 没哭出来的眼泪 | +| `fu_item_melody.mp3` | 那段旋律 | +| `fu_item_needed.mp3` | 被需要的感觉 | +| `fu_item_alone.mp3` | 独处的自在 | +| `fu_item_fear.mp3` | 害怕 | +| `fu_item_curious.mp3` | 好奇心 | +| `fu_item_tired.mp3` | 疲惫 | +| `fu_item_nameless.mp3` | 无名之物 | +| `fu_sep.mp3` | 、(顿号停顿,约200ms静音) | + +--- + +## 十、统计 + +| 游戏 | 固定台词条数 | 条件分支条数 | 动态/TTS条数 | 合计 | +|------|------------|------------|------------|------| +| 林夏 | 38 | 5 | 0 | **43** | +| 一念 | 16 | 4 | 0 | **20** | +| 守夜 | 20 | 2 | 0 | **22** | +| 候诊 | 22 | 0 | 1(结局) | **22+动态** | +| 遗声 | 34 | 0 | 0 | **34** | +| 浮 | 37 | 0 | 1(结局) | **37+动态** | +| **合计** | **167** | **11** | **2** | **~178** | + +--- + +## 十一、TTS 生成优先级 + +建议按以下顺序生成,优先保证核心体验: + +1. **P0(必须)**:intro、所有 narrate、所有 tap/dt 旁白 +2. **P1(重要)**:所有 ending +3. **P2(可选)**:条件分支变体(_a/_b) +4. **P3(可选)**:浮的动态结局分片 diff --git a/game/index.html b/game/index.html index 0eb54dc..6c77dde 100644 --- a/game/index.html +++ b/game/index.html @@ -6,155 +6,47 @@ - 林夏 + 盲游 -
+
+

 
-
-

林夏

-
- 今晚 16 条消息
- 你接谁,不接谁
- 决定明天的自己 -
-
-
操 作 说 明
+ + + + -
-
来电时
-
单击 / 右滑接听
-
左滑 / 双击拒接
-
+ + + + -
-
收到消息
-
单击播放
-
双击 / 左滑跳过
-
+ + + + + + + -
-
选择回复
-
单击简单回应
-
右滑温暖回应
-
左滑冷淡回应
-
上滑追问
-
下滑沉默
-
双击不回复
-
- -
-
播放中
-
双击跳过
-
长按重播
-
- -
-
随时可用
-
两指长按暂停
-
三指点击退出(暂停后)
-
长按播报未读数
-
-
- -
· 轻触屏幕开始 ·
-
- 建议关灯 · 戴上耳机
- 按你的节奏来 -
-
- 本游戏涉及孤独、家庭关系等情感话题
- 请在舒适的状态下体验 -
-
- -
-
-
- - - - - - - - - + + + diff --git a/game/js/audio.js b/game/js/audio.js index 50b5c27..0ff71b4 100644 --- a/game/js/audio.js +++ b/game/js/audio.js @@ -76,6 +76,7 @@ class AudioManager { // ── 铃声(循环)── async startRingtone(url) { this.stopRingtone(); + if (typeof TEST !== 'undefined' && TEST) return; this._ringEl = new Audio(url); this._ringEl.loop = true; this._ringEl.volume = 1.0; @@ -93,6 +94,10 @@ class AudioManager { // ── 语音播放(多段顺序)── playVoice(urls, { vol = 1.0, onEnd = null } = {}) { this.stopVoice(); + if (typeof TEST !== 'undefined' && TEST) { + if (onEnd) onEnd(); + return; + } this._voiceQueue = [...urls]; this._playNext(vol, onEnd); } diff --git a/game/js/engine.js b/game/js/engine.js index a3c3f1f..41134bf 100644 --- a/game/js/engine.js +++ b/game/js/engine.js @@ -1,842 +1,180 @@ -// engine.js — 游戏引擎 v2(多轮对话 + 林夏语音 + NPC反应) +// engine.js — 场景引擎 // -// 状态流:idle → notification/ringing → playing → choosing -// → responding → reacting → [choosing | idle] → …… → ending +// 场景数据结构: +// narrate : string | fn(state)→string 旁白文本(TTS 兜底) +// narrateAudio : string | fn(state)→string 旁白预录 MP3 路径(优先于 TTS) +// audio : string[] 角色语音(播放完后才显示选项) +// bgm : string | null | undefined undefined=不变 null=停止 string=切换 +// delay : number 自动跳转前的等待毫秒 +// tap : { label, narrate, narrateAudio, audio, state, next } 单击操作 +// doubleTap : { label, narrate, narrateAudio, audio, state, next } 双击操作 +// next : string | fn(state) | null 无选项时自动跳转(null=结局) // -// 新增状态: -// responding — 林夏说话中(打字音效 + 语音播放) -// reacting — NPC反应播放中 -// paused — 游戏暂停(两指长按触发) +// state/next 可以是函数 fn(currentState)=>值 -class GameEngine { +class SceneEngine { constructor() { - this._state = 'idle'; - - // 剧情变量 - this.vars = { - MOM_LINK: false, - HE_BACK: null, - GROUP_REPLY: false, - ZHOUNAN_DEPTH: 0, - ZHOUNAN_SHARE: false, - SELF_RECORD: false, - UNREAD: 0, - REPLY_COUNT: 0, - }; - - this.profile = new ProfileTracker(); - this.msgState = {}; - this.currentMsg = null; - - this._queue = []; - this._endingTriggered = false; - this._reminderTimer = null; - - // v2: 对话系统 - this._currentChoices = null; // 当前可用的选项集 - this._pendingResolve = null; // 用于中断语音播放的 resolve - this._skipRequested = false; // 跳过标志 - - // 暂停系统 - this._pausedState = null; // 暂停前的状态 - - this.gest = null; + this._game = null; + this._state = {}; + this._scene = null; + this._waiting = false; + this._input = null; + this._onEnd = null; } - // ── 状态 getter/setter ── - get state() { return this._state; } - set state(s) { - this._state = s; - dbg(`state: ${s}`); - const el = document.getElementById('hint'); - if (!el) return; - const H = { - idle: '', - notification: '单击 播放 · 双击/左滑 跳过', - ringing: '单击/右滑 接听 · 左滑/双击 拒接', - playing: '双击 跳过 · 长按 重播', - choosing: '单击回应 · →温暖 ←冷淡 ↑追问 ↓沉默 · 双击跳过', - responding: '双击 跳过', - reacting: '双击 跳过', - paused: '单击 继续 · 三指点击 退出', - ending: '', - }; - el.textContent = H[s] || ''; - } + // ── 运行游戏 ── + async run(game, input, onEnd) { + this._game = game; + this._state = JSON.parse(JSON.stringify(game.initialState || {})); + this._scene = null; + this._waiting = false; + this._input = input; + this._onEnd = onEnd; - // ══════════════════════════════════════════ - // 初始化 & 启动 - // ══════════════════════════════════════════ + // 接管输入 + input.onTap = () => this._tap(); + input.onDoubleTap = () => this._dt(); - init(gestureDetector) { - this.gest = gestureDetector; - this.gest - .on('singletap', () => this._onTap()) - .on('doubletap', () => this._onDoubleTap()) - .on('swipe_left', () => this._onSwipe('left')) - .on('swipe_right', () => this._onSwipe('right')) - .on('swipe_up', () => this._onSwipe('up')) - .on('swipe_down', () => this._onSwipe('down')) - .on('longpress', () => this._onLongPress()) - .on('twofinger_longpress', () => this._onPause()) - .on('threefinger_tap', () => this._onExit()); - } - - async start() { - this._queue = [...MESSAGES]; - - // 内容预警 - await this._playVoiceAsync([`${T}/v2_sys_content_warning_wav_v1.mp3`]); - await sleep(1000); - - await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.15 }); - await sleep(1500); - Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.mp3`, { vol: 0.1 }); - await sleep(1500); - - this._next(); - } - - // ══════════════════════════════════════════ - // 语音播放(异步 + 可中断) - // ══════════════════════════════════════════ - - _playVoiceAsync(urls) { - return new Promise(resolve => { - this._pendingResolve = resolve; - Snd.playVoice(urls, { - onEnd: () => { - this._pendingResolve = null; - resolve(); - }, - }); - }); - } - - _interruptVoice() { + // 清场 + Narrator.stop(); Snd.stopVoice(); - TTS.stop(); - if (this._pendingResolve) { - this._pendingResolve(); - this._pendingResolve = null; - } + Snd.stopBGM(0); + Snd.stopAmbience(); + + dbg(`[engine] start ${game.id}`); + + if (game.bgm) Snd.startBGM(game.bgm, { vol: 0.18 }); + if (game.ambience) Snd.startAmbience(game.ambience, { vol: 0.15 }); + + await Narrator.sayAndWait(game.intro); + await this._goto(game.first); } - // 打字 + 发送音效 - async _playTypingSend() { - SFX_UI.typing(); - await sleep(800); - if (this._skipRequested) return; - SFX_UI.send(); - await sleep(300); - } - - // ══════════════════════════════════════════ - // 消息队列推进 - // ══════════════════════════════════════════ - - async _next() { - if (this._endingTriggered) return; - - const msg = this._queue.shift(); - if (!msg) { - this.triggerFinalEnding(); + // ── 跳转到场景 ── + async _goto(id) { + if (id === null || id === undefined) { + this._ending(); return; } - if (msg.requiresMomLink && !this.vars.MOM_LINK) { - this.triggerFinalEnding(); + const scene = this._game.scenes[id]; + if (!scene) { console.error('[engine] missing scene:', id); return; } + + this._scene = scene; + this._waiting = false; + Narrator.clearOptions(); + + dbg(`[scene] ${id}`); + + // BGM 切换 + if (scene.bgm !== undefined) { + if (scene.bgm) Snd.startBGM(scene.bgm, { vol: 0.18 }); + else Snd.stopBGM(1500); + } + + // 播放音频(等完) + const sceneAudio = this._r(scene.audio); + if (sceneAudio && sceneAudio.length > 0) { + await this._playAudio(sceneAudio); + } + + // 旁白描述(等完)— 优先播放预录音频,否则 TTS + if (scene.narrateAudio || scene.narrate) { + await this._sayNarrate(scene); + } + + // 有选项 → 等待输入 + if (scene.tap || scene.doubleTap) { + this._waiting = true; + Narrator.options( + scene.tap ? scene.tap.label : null, + scene.doubleTap ? scene.doubleTap.label : null, + ); return; } - this.currentMsg = msg; - this._currentChoices = msg.choices || null; - this.msgState[msg.id] = 'unread'; - this.vars.UNREAD++; - - if (msg.type === 'call') { - this._startRinging(msg); - } else { - this._notifyMessage(msg); - } + // 无选项 → 自动跳转 + if (scene.delay) await new Promise(r => setTimeout(r, scene.delay)); + await this._goto(this._r(scene.next)); } - _notifyMessage(msg) { - this.state = 'notification'; - Haptic.message(); - SFX_UI.notify(); - this._startReminder(); - } + // ── 执行一个操作 ── + async _exec(action) { + this._waiting = false; + Narrator.clearOptions(); + Narrator.stop(); // 打断正在播报的选项提示 - async _startRinging(msg) { - this.state = 'ringing'; - Haptic.ring(); - await Snd.startRingtone(msg.ringtone); - this._startReminder(); - } - - // ══════════════════════════════════════════ - // 通话流程(异步) - // ══════════════════════════════════════════ - - async _answerCall(msg) { - Snd.stopRingtone(); - Haptic.stop(); - Haptic.confirm(); - SFX_UI.tap(); - this._clearReminder(); - - this.state = 'playing'; - this.msgState[msg.id] = 'read'; - this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1); - - if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer); - if (msg.profileOnAnswer) this.profile.add(msg.profileOnAnswer); - - this._skipRequested = false; - - // 播放通话内容 - await this._playVoiceAsync(msg.audio); - - // _onDoubleTap 在 'playing' 状态下会直接处理状态转换 - // 所以如果状态已不是 'playing',说明跳过已被处理,直接返回 - if (this.state !== 'playing') return; - - // 自动挂断(MSG-08 未知号码) - if (msg.autoHangup) { - if (msg.sfxAfter) { - const sfx = Array.isArray(msg.sfxAfter) ? msg.sfxAfter[0] : msg.sfxAfter; - await this._playVoiceAsync([sfx]); - if (this.state !== 'playing') return; - } - this._proceedToNext(); - return; - } - - // 自动回复(MSG-01/03/14 妈妈电话、外卖、妈妈第二次电话) - if (msg.autoReply) { - await this._playTypingSend(); - if (this.state !== 'playing') return; - - this.state = 'responding'; - await this._playVoiceAsync([msg.autoReply]); - - // 条件反应(MSG-14:先播前导语"真的没事?",再根据 MOM_LINK 播放不同反应) - if (!this._skipRequested && msg.autoReact) { - this.state = 'reacting'; - // 前导语(如"真的没事?") - if (msg.autoReactPreamble) { - await sleep(500); - if (!this._skipRequested) { - await this._playVoiceAsync([msg.autoReactPreamble]); - } - } - // 条件分支反应 - if (!this._skipRequested) { - const val = this.vars[msg.autoReact.condition]; - const reactUrl = val ? msg.autoReact['true'] : msg.autoReact['false']; - if (reactUrl) { - await sleep(400); - if (!this._skipRequested) { - await this._playVoiceAsync([reactUrl]); - } - } - } - } - this._proceedToNext(); - return; - } - - // 通话后有选择(MSG-11 阿哲) - if (msg.choices) { - this._enterChoosing(msg.choices); - return; - } - - this._proceedToNext(); - } - - _rejectCall(msg) { - Snd.stopRingtone(); - Haptic.reject(); - SFX_UI.skip(); - this._clearReminder(); - this.msgState[msg.id] = 'missed'; - if (msg.profileOnMiss) this.profile.add(msg.profileOnMiss); - this._proceedToNext(); - } - - // ══════════════════════════════════════════ - // 消息播放(异步) - // ══════════════════════════════════════════ - - async _playMessage(msg) { - this._clearReminder(); - this.state = 'playing'; - this.msgState[msg.id] = 'read'; - this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1); - - if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.1 }); - - // 系统通知(时光胶囊) - if (msg.type === 'system_notification') { - this._playSystemNotification(msg); - return; - } - - this._skipRequested = false; - - // 文字消息(TTS 朗读,不可用时播放提示音) - if (msg.type === 'wechat_text' && msg.text) { - const ok = await TTS.speak(msg.text); - if (!ok) { - SFX_UI.notify(); - await sleep(600); - } - } - // 语音消息 - else if (msg.audio?.length > 0) { - await this._playVoiceAsync(msg.audio); - } - - if (this._skipRequested) return; - if (this.state !== 'playing') return; - - this._afterPlayed(msg); - } - - _playSystemNotification(msg) { - Snd.playVoice([msg.systemAudio], { - onEnd: () => { - if (this.state !== 'playing') return; - const audio = msg.audioByProfile?.[this.profile.label]; - if (audio) { - if (msg.stateOnPlay) this._applyState(msg.stateOnPlay); - if (msg.profileOnPlay) this.profile.add(msg.profileOnPlay); - setTimeout(() => { - if (this.state !== 'playing') return; - Snd.playVoice([audio], { - onEnd: () => this._proceedToNext(), - }); - }, 800); - } else { - this._proceedToNext(); - } - }, - }); - } - - _afterPlayed(msg) { - if (this.state !== 'playing') return; - - if (msg.isEnding) { - this.triggerFinalEnding(); - return; - } - - if (msg.choices) { - this._enterChoosing(msg.choices); - return; - } - - this._proceedToNext(); - } - - // ══════════════════════════════════════════ - // 选择系统(v2 多轮对话核心) - // ══════════════════════════════════════════ - - _enterChoosing(choices) { - this._currentChoices = choices; - this.state = 'choosing'; - SFX_UI.choiceReady(); - Haptic.confirm(); - Snd.playVoice([`${T}/v2_sys_wait_reply_wav_v1.mp3`]); - this._startReminder(); - } - - async _handleChoice(dir) { - const choices = this._currentChoices; - if (!choices) { - SFX_UI.error(); - Haptic.error(); - return; - } - - const choice = choices[dir]; - if (!choice) { - SFX_UI.error(); - Haptic.error(); - return; - } - - this._clearReminder(); - this._skipRequested = false; - - // 1. 播放方向对应的情感音效 - const sfxMap = { - tap: 'tap', - right: 'choiceWarm', - left: 'choiceCold', - up: 'choiceCurious', - down: 'choiceSilent', - }; - if (sfxMap[dir]) SFX_UI[sfxMap[dir]](); Haptic.select(); - // 2. 应用状态和画像 - if (!choice.isSilence) { - this.msgState[this.currentMsg.id] = 'replied'; - this.vars.REPLY_COUNT++; - } - if (choice.state) this._applyState(choice.state); - if (choice.profile) this.profile.add(choice.profile); - - // 3. 林夏回应 - this.state = 'responding'; - - if (choice.isSilence) { - // 沉默:短暂停顿 - await sleep(1500); - } else if (choice.linxia) { - // 打字 → 发送 → 林夏语音 - await this._playTypingSend(); - if (!this._skipRequested) { - await this._playVoiceAsync([choice.linxia]); - } + // 更新状态 + if (action.state) { + const updates = this._r(action.state); + Object.assign(this._state, updates); } - if (this._skipRequested) { - // 跳过了林夏回应 → 如有 followUp 进入选择,否则跳到下一条 - if (choice.followUp?.choices) { - this._enterChoosing(choice.followUp.choices); - return; - } - this._proceedToNext(); - return; + // 反应音频(支持函数型) + const actionAudio = this._r(action.audio); + if (actionAudio && actionAudio.length > 0) { + await this._playAudio(actionAudio); } - // 4. NPC 反应 - if (choice.npcReact?.length > 0) { - this.state = 'reacting'; - await sleep(400); - if (!this._skipRequested) { - await this._playVoiceAsync(choice.npcReact); - } + // 反应旁白 + if (action.narrateAudio || action.narrate) { + await this._sayNarrate(action); } - if (this._skipRequested) { - if (choice.followUp?.choices) { - this._enterChoosing(choice.followUp.choices); - return; - } - this._proceedToNext(); - return; - } - - // 5. 如有后续对话 → 再次进入选择 - if (choice.followUp?.choices) { - await sleep(300); - this._enterChoosing(choice.followUp.choices); - return; - } - - // 6. 推进到下一条 - this._proceedToNext(); + await this._goto(this._r(action.next)); } - _skipChoice() { - this._clearReminder(); - SFX_UI.skip(); - Haptic.confirm(); - this.profile.add({ avoidance: 1 }); - this._proceedToNext(); + _tap() { + if (!this._waiting || !this._scene?.tap) return; + this._exec(this._scene.tap); } - // ══════════════════════════════════════════ - // 推进到下一条 - // ══════════════════════════════════════════ - - async _proceedToNext() { - const prev = this.currentMsg; - this.currentMsg = null; - this._currentChoices = null; - this.state = 'idle'; - this._clearReminder(); - - if (this._endingTriggered) return; - - // 特殊事件:阿哲来了(门铃) - if (prev?.id === 11 && this.vars.HE_BACK === true) { - await sleep(1500); - Snd.playVoice([`${SFX}/sfx_door_knock_gentle_v1.mp3`]); - Haptic.ring(); - await sleep(3000); - } - - // 检查结局D:未读过多 - const triggered = MESSAGES.length - this._queue.length; - if (this.vars.UNREAD >= 8 && triggered >= 14) { - this._triggerEnding('D'); - return; - } - - // 短暂停顿后推进 - SFX_UI.next(); - await sleep(DEV ? 200 : 2000); - this._next(); + _dt() { + if (!this._waiting || !this._scene?.doubleTap) return; + this._exec(this._scene.doubleTap); } - // ══════════════════════════════════════════ - // 提醒定时器 - // ══════════════════════════════════════════ - - _startReminder() { - this._clearReminder(); - this._reminderTimer = setInterval(() => { - if (this.state === 'notification') { - SFX_UI.notify(); - Haptic.message(); - } else if (this.state === 'choosing') { - SFX_UI.choiceReady(); - Haptic.confirm(); - } else if (this.state === 'ringing') { - Haptic.ring(); - } else { - this._clearReminder(); - } - }, 8000); - } - - _clearReminder() { - if (this._reminderTimer) { - clearInterval(this._reminderTimer); - this._reminderTimer = null; - } - } - - // ══════════════════════════════════════════ - // 状态变量 - // ══════════════════════════════════════════ - - _applyState(changes) { - for (const [k, v] of Object.entries(changes)) { - if (k === 'ZHOUNAN_DEPTH_ADD') { - this.vars.ZHOUNAN_DEPTH += (v || 1); - } else if (v !== null) { - this.vars[k] = v; - } - } - } - - // ══════════════════════════════════════════ - // 手势处理 - // ══════════════════════════════════════════ - - _onTap() { - const s = this.state; - - // 暂停中 → 恢复 - if (s === 'paused') { - this._onResume(); - return; - } - - if (s === 'ringing') { - this._answerCall(this.currentMsg); - } else if (s === 'notification') { - SFX_UI.tap(); - Haptic.confirm(); - this._playMessage(this.currentMsg); - } else if (s === 'choosing') { - this._handleChoice('tap'); - } else if (s === 'idle') { - SFX_UI.error(); - Haptic.error(); - } - } - - _onDoubleTap() { - const s = this.state; - - if (s === 'paused') return; - - if (s === 'ringing') { - this._rejectCall(this.currentMsg); - return; - } - - if (s === 'notification') { - this._clearReminder(); - SFX_UI.skip(); - Haptic.confirm(); - const msg = this.currentMsg; - if (msg?.profileOnSkip) this.profile.add(msg.profileOnSkip); - else this.profile.add({ avoidance: 1 }); - this._proceedToNext(); - return; - } - - if (s === 'playing') { - this._skipRequested = true; - this._interruptVoice(); - SFX_UI.skip(); - Haptic.confirm(); - const msg = this.currentMsg; - if (msg?.isEnding) { - this.triggerFinalEnding(); - } else if (msg?.choices) { - this._enterChoosing(msg.choices); - } else { - this._proceedToNext(); - } - return; - } - - if (s === 'responding' || s === 'reacting') { - this._skipRequested = true; - this._interruptVoice(); - SFX_UI.skip(); - Haptic.confirm(); - return; - } - - if (s === 'choosing') { - this._skipChoice(); - return; - } - - if (s === 'idle') { - SFX_UI.error(); - Haptic.error(); - } - } - - _onSwipe(dir) { - const s = this.state; - - if (s === 'paused') return; - - if (s === 'ringing') { - if (dir === 'left') this._rejectCall(this.currentMsg); - else if (dir === 'right') this._answerCall(this.currentMsg); - return; - } - - if (s === 'notification') { - if (dir === 'left') { - this._clearReminder(); - SFX_UI.skip(); - Haptic.confirm(); - this.profile.add({ avoidance: 1 }); - this._proceedToNext(); - } - return; - } - - if (s === 'choosing') { - this._handleChoice(dir); - return; - } - - if (s === 'idle') { - SFX_UI.error(); - Haptic.error(); - } - } - - // ── 长按:重播 / 状态广播 ── - _onLongPress() { - const s = this.state; - - if (s === 'paused' || s === 'ending') return; - - Haptic.confirm(); - - // playing/responding/reacting/choosing → 重播当前消息音频 - if ((s === 'playing' || s === 'responding' || s === 'reacting' || s === 'choosing') && this.currentMsg) { - this._interruptVoice(); - if (this.currentMsg.audio?.length > 0) { - this._playVoiceAsync(this.currentMsg.audio); - } else if (this.currentMsg.type === 'wechat_text' && this.currentMsg.text) { - TTS.speak(this.currentMsg.text).then(ok => { if (!ok) SFX_UI.notify(); }); - } - return; - } - - // idle/notification → 广播未读数 - TTS.speak(`当前未读${this.vars.UNREAD}条消息`).then(ok => { if (!ok) SFX_UI.error(); }); - } - - // ── 暂停 ── - _onPause() { - const s = this.state; - if (s === 'ending' || s === 'paused') return; - - this._pausedState = s; - this._interruptVoice(); - this._clearReminder(); - Haptic.stop(); - this.state = 'paused'; - Snd.playVoice([`${T}/v2_sys_paused_wav_v1.mp3`]); - } - - // ── 恢复 ── - _onResume() { - Snd.stopVoice(); - const prev = this._pausedState || 'idle'; - this._pausedState = null; - - // 语音无法断点续播,根据暂停前状态决定恢复行为 - if (prev === 'choosing' && this._currentChoices) { - this.state = 'choosing'; - SFX_UI.choiceReady(); - Haptic.confirm(); - this._startReminder(); - } else if (prev === 'notification' || prev === 'ringing') { - this.state = prev; - this._startReminder(); - } else { - // playing/responding/reacting → 无法续播,推进到下一条或回到选择 - if (this._currentChoices) { - this._enterChoosing(this._currentChoices); - } else { - this._proceedToNext(); - } - } - } - - // ── 退出 ── - _onExit() { - if (this.state !== 'paused') return; - - this._endingTriggered = true; - this._interruptVoice(); - // 退出不需要特殊音频,直接返回开始页 - setTimeout(() => { - const startEl = document.getElementById('start'); - if (startEl) { - startEl.style.opacity = '1'; - startEl.style.display = 'flex'; - } - }, 2000); - } - - // ══════════════════════════════════════════ - // 结局系统 - // ══════════════════════════════════════════ - - async triggerFinalEnding() { - if (this._endingTriggered) return; - - const v = this.vars; - - // 结局E(隐藏)— 优先级 1 - if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE && - (this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) { - return this._triggerEnding('E'); - } - - // 结局A:妈妈连接 + 自己录音 — 优先级 2 - if (v.MOM_LINK && v.SELF_RECORD) { - return this._triggerEnding('A'); - } - - // 结局B:拒了阿哲 + 回了周南 — 优先级 3 - if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) { - return this._triggerEnding('B'); - } - - // 结局C:全部已读但极少回复 — 优先级 4 - const read = Object.values(this.msgState).filter(s => s !== 'unread').length; - if (read >= MESSAGES.length - 2 && v.REPLY_COUNT <= 2) { - return this._triggerEnding('C'); - } - - // 结局D(兜底)— 优先级 5 - this._triggerEnding('D'); - } - - async _triggerEnding(type) { - if (this._endingTriggered) return; - this._endingTriggered = true; - - this.state = 'ending'; - this._clearReminder(); - this._interruptVoice(); - Snd.stopRingtone(); + // ── 结局 ── + async _ending() { Snd.stopBGM(3000); - await sleep(2000); - - dbg(`ending: ${type}`); - - const fn = { - A: () => this._endingA(), - B: () => this._endingB(), - C: () => this._endingC(), - D: () => this._endingD(), - E: () => this._endingE(), - }[type]; - if (fn) await fn(); - - // 结局后播报心理支持热线 - await sleep(3000); - await this._playVoiceAsync([`${T}/v2_sys_hotline_wav_v1.mp3`]); - } - - async _endingA() { - Snd.startAmbience(`${AMB}/amb_kitchen_morning_v1.mp3`, { vol: 0.3 }); - await sleep(2000); - Snd.playVoice([`${SFX}/sfx_kettle_whistle_v1.mp3`]); - await sleep(4000); + Snd.stopAmbience(); + const id = this._game.getEnding(this._state); + const e = this._game.endings?.[id]; + dbg(`[ending] ${id}`); SFX_UI.ending(); - this._showCaption('明天再说。'); - await this._playVoiceAsync([`${T}/v2_ending_a_linxia_wav_v1.mp3`]); + if (e) { + await this._sayNarrate(e); + } else { + await Narrator.sayAndWait('游戏结束。'); + } + await new Promise(r => setTimeout(r, 2000)); + await Narrator.sayAndWait('感谢体验。'); + await new Promise(r => setTimeout(r, 3000)); + if (this._onEnd) this._onEnd(); } - async _endingB() { - await sleep(2000); - SFX_UI.ending(); - await sleep(1000); - this._showCaption('寄出去了。'); - await this._playVoiceAsync([`${T}/v2_ending_b_linxia_wav_v1.mp3`]); + // ── 旁白:优先预录 MP3,兜底 TTS ── + _sayNarrate(node) { + const audioPath = this._r(node.narrateAudio); + if (audioPath) { + return this._playAudio([audioPath]); + } + const text = this._r(node.narrate); + if (text) return Narrator.sayAndWait(text); + return Promise.resolve(); } - async _endingC() { - await sleep(3000); - SFX_UI.ending(); - await sleep(1000); - this._showCaption('今晚就这样。'); - await this._playVoiceAsync([`${T}/v2_ending_c_linxia_wav_v1.mp3`]); + // ── 播放多段音频(顺序,等完)── + _playAudio(urls) { + return new Promise(resolve => { + Snd.playVoice(urls, { onEnd: resolve }); + }); } - async _endingD() { - Haptic.system(); - await sleep(2000); - Haptic.ring(); - await sleep(4000); - Snd.startAmbience(`${AMB}/amb_dawn_birds_v1.mp3`, { vol: 0.4 }); - await sleep(4000); - this._showCaption('早上好。'); - await this._playVoiceAsync([`${T}/v2_ending_d_linxia_wav_v1.mp3`]); - } - - async _endingE() { - Haptic.message(); - SFX_UI.notify(); - await sleep(1500); - // 周南秒回:"那我下次出差来北京,请你吃饭好不好。" - await this._playVoiceAsync([`${T}/v2_ending_e_zhounan_wav_v1.mp3`]); - await sleep(2000); - SFX_UI.ending(); - await sleep(1000); - this._showCaption('好。'); - await this._playVoiceAsync([`${T}/v2_ending_e_linxia_wav_v1.mp3`]); - } - - _showCaption(text) { - const el = document.getElementById('caption'); - el.textContent = text; - el.style.display = 'flex'; + // ── 解析 string | fn(state) ── + _r(val) { + return typeof val === 'function' ? val(this._state) : val; } } diff --git a/game/js/games/fu.js b/game/js/games/fu.js new file mode 100644 index 0000000..ed42c5e --- /dev/null +++ b/game/js/games/fu.js @@ -0,0 +1,125 @@ +// games/fu.js — 《浮》 +// 感官漂流。十二个声音漂过来,抓住还是放走。 +// 结局:旁白读出你带走了什么。 + +const GAME_FU = { + id: 'fu', + intro: '你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。单击,把手伸过去。双击,看着它走。', + first: 'f01', + bgm: null, + ambience: '../audio/mp3/ambience/amb_apartment_night_01_v1.mp3', + initialState: { kept: [], released: [] }, + + scenes: { + f01: { + delay: 2000, + narrate: '第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。', + tap: { label: '把它留住', narrate: '它停在你手心里,有点暖。', + state: s => ({ kept: [...s.kept, '夏天的风'], released: s.released }), next: 'f02' }, + doubleTap:{ label: '让它走', narrate: '你看着它慢慢飘走了。', + state: s => ({ kept: s.kept, released: [...s.released, '夏天的风'] }), next: 'f02' }, + }, + f02: { + delay: 1500, + narrate: '第二个。某人的笑声。你认识这个笑,但你说不清是谁的。', + tap: { label: '留住它', narrate: '笑声停在你耳边,还在回响。', + state: s => ({ kept: [...s.kept, '笑声'], released: s.released }), next: 'f03' }, + doubleTap:{ label: '让它走', narrate: '笑声远了。', + state: s => ({ kept: s.kept, released: [...s.released, '笑声'] }), next: 'f03' }, + }, + f03: { + delay: 1500, + narrate: '第三个。一种说不清楚的遗憾。它没有形状,但是很重。', + tap: { label: '接住它', narrate: '你把遗憾抱在怀里了。它很重,但你没有放开。', + state: s => ({ kept: [...s.kept, '遗憾'], released: s.released }), next: 'f04' }, + doubleTap:{ label: '让它过去', narrate: '遗憾漂过去了。你呼了口气。', + state: s => ({ kept: s.kept, released: [...s.released, '遗憾'] }), next: 'f04' }, + }, + f04: { + delay: 1500, + narrate: '第四个。很久没有过的安全感。轻轻的,像一床旧被子的气味。', + tap: { label: '留住它', narrate: '你把它裹在身上了。', + state: s => ({ kept: [...s.kept, '安全感'], released: s.released }), next: 'f05' }, + doubleTap:{ label: '放走', narrate: '安全感飘走了。你看着它走。', + state: s => ({ kept: s.kept, released: [...s.released, '安全感'] }), next: 'f05' }, + }, + f05: { + delay: 1500, + narrate: '第五个。想哭但没哭出来的那种感觉。像喉咙里卡着什么。', + tap: { label: '承认它', narrate: '你承认了。那个东西松动了一点点。', + state: s => ({ kept: [...s.kept, '没哭出来的眼泪'], released: s.released }), next: 'f06' }, + doubleTap:{ label: '让它走', narrate: '它漂走了。喉咙还是有点紧。', + state: s => ({ kept: s.kept, released: [...s.released, '没哭出来的眼泪'] }), next: 'f06' }, + }, + f06: { + delay: 1500, + narrate: '第六个。一首旋律,只有开头几个音,后面的你想不起来了。', + tap: { label: '哼几下', narrate: '你在心里哼了那几个音。后面的还是想不起来,但这几个音够了。', + state: s => ({ kept: [...s.kept, '那段旋律'], released: s.released }), next: 'f07' }, + doubleTap:{ label: '算了', narrate: '旋律消失了。', + state: s => ({ kept: s.kept, released: [...s.released, '那段旋律'] }), next: 'f07' }, + }, + f07: { + delay: 1500, + narrate: '第七个。被需要的感觉。有人曾对你说过你很重要,你还记得那句话。', + tap: { label: '带着它', narrate: '你把那句话放进口袋里了。', + state: s => ({ kept: [...s.kept, '被需要的感觉'], released: s.released }), next: 'f08' }, + doubleTap:{ label: '放开', narrate: '那句话漂走了。', + state: s => ({ kept: s.kept, released: [...s.released, '被需要的感觉'] }), next: 'f08' }, + }, + f08: { + delay: 1500, + narrate: '第八个。一个人的时候的自在。没人看着,没人要你做什么,只是你自己。', + tap: { label: '收下', narrate: '你把这份自在收进来了。', + state: s => ({ kept: [...s.kept, '独处的自在'], released: s.released }), next: 'f09' }, + doubleTap:{ label: '不需要', narrate: '它漂走了。也许你现在不需要。', + state: s => ({ kept: s.kept, released: [...s.released, '独处的自在'] }), next: 'f09' }, + }, + f09: { + delay: 1500, + narrate: '第九个。害怕。不知道怕什么,就是有点怕。', + tap: { label: '承认害怕', narrate: '你承认了:我有点害怕。说出来之后,它小了一点。', + state: s => ({ kept: [...s.kept, '害怕'], released: s.released }), next: 'f10' }, + doubleTap:{ label: '不理它', narrate: '你没有看它,害怕漂走了。也许。', + state: s => ({ kept: s.kept, released: [...s.released, '害怕'] }), next: 'f10' }, + }, + f10: { + delay: 1500, + narrate: '第十个。好奇心。对某件事、某个人、某个地方,你还有好奇。', + tap: { label: '带上它', narrate: '好奇心跳进你手里了,很活跃。', + state: s => ({ kept: [...s.kept, '好奇心'], released: s.released }), next: 'f11' }, + doubleTap:{ label: '放走', narrate: '好奇心漂走了,也许它去找别人了。', + state: s => ({ kept: s.kept, released: [...s.released, '好奇心'] }), next: 'f11' }, + }, + f11: { + delay: 1500, + narrate: '第十一个。疲惫。真实的、身体里的疲惫,它已经跟了你很久了。', + tap: { label: '承认疲惫', narrate: '你说:我累了。疲惫点了点头,说我知道。', + state: s => ({ kept: [...s.kept, '疲惫'], released: s.released }), next: 'f12' }, + doubleTap:{ label: '让它走', narrate: '疲惫漂走了。你感觉稍微轻了一点点。', + state: s => ({ kept: s.kept, released: [...s.released, '疲惫'] }), next: 'f12' }, + }, + f12: { + delay: 2000, + narrate: '最后一个。它没有名字。是你漂浮在这里时特有的某种感觉,说不清,但它在。', + tap: { label: '带走它', narrate: '你把它揣在怀里了,连同它的没有名字。', + state: s => ({ kept: [...s.kept, '无名之物'], released: s.released }), next: null }, + doubleTap:{ label: '留在这里', narrate: '你看着它留在了这片水里,这片梦里。', + state: s => ({ kept: s.kept, released: [...s.released, '无名之物'] }), next: null }, + }, + }, + + getEnding: () => 'only', + + endings: { + only: { + narrate: s => { + let t = '你漂回来了。'; + if (s.kept.length > 0) t += `你带走了:${s.kept.join('、')}。`; + if (s.released.length > 0) t += `你放走了:${s.released.join('、')}。`; + t += '这就是今天的你。'; + return t; + }, + }, + }, +}; diff --git a/game/js/games/houzhen.js b/game/js/games/houzhen.js new file mode 100644 index 0000000..8f22ec7 --- /dev/null +++ b/game/js/games/houzhen.js @@ -0,0 +1,206 @@ +// games/houzhen.js — 《候诊》 +// 等待检查结果的两小时。 +// 单击=把注意力放在外面,双击=退回到自己里面。 +// 没有好坏结局,只有你等待的方式。 + +const GAME_HOUZHEN = { + id: 'houzhen', + intro: '下午一点半,你坐在医院候诊室,等一个检查结果。叫号机已经叫到了八十四号。你是九十四号。单击,把注意力放到外面。双击,退回到自己里面。', + first: 'h01', + bgm: null, + ambience: null, + initialState: { out: 0, inside: 0, moments: [] }, + + scenes: { + // 1. 建立等待感(无选择) + h01: { + delay: 3000, + narrate: '叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。', + next: 'h02', + }, + + // 2. 左边的老人 + h02: { + delay: 1000, + narrate: '你左边坐着一个老人。他在看窗外,窗外是停车场,没有什么特别的。但他看得很专注。', + tap: { + label: '看看他', + narrate: '你看了他一会儿。他感觉到了,朝你点了点头。你也点了点头。没有说话,但有什么东西发生了。', + state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '和老人点头'] }), + next: 'h03_wait', + }, + doubleTap: { + label: '低头看手机', + narrate: '你低下头,看手机。屏幕亮着,什么都有,但你没有真的看进去什么。', + state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }), + next: 'h03_wait', + }, + }, + + h03_wait: { + delay: 5000, + narrate: '叫号机叫到了八十八号。', + next: 'h03', + }, + + // 3. 一条旧消息 + h03: { + delay: 1000, + narrate: '你手机上有一条两天前的消息,你当时看到了,但没有回。那个人说:最近怎么样。', + tap: { + label: '打开来回复', + narrate: '你打开来,打了几个字。发出去了。不知道他会不会在意这么晚才回。', + state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '回了两天前的消息'] }), + next: 'h04_wait', + }, + doubleTap: { + label: '先不管它', + narrate: '你把手机翻过去,屏幕朝下放在大腿上。等等再说。', + state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }), + next: 'h04_wait', + }, + }, + + h04_wait: { + delay: 4000, + narrate: '护士从走廊那头走过来,叫了一个名字,不是你。', + next: 'h04', + }, + + // 4. 一个孩子在哭 + h04: { + delay: 1000, + narrate: '对面有个小孩在哭,大约三四岁,蹲在椅子旁边哭。旁边的大人在轻声说什么,但他还是哭。', + tap: { + label: '看过去', + narrate: '你看着那个孩子。他哭了一会儿就停了,大概是哭够了。你们没有交流,但你陪着他哭完了。', + state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '陪孩子哭完了'] }), + next: 'h05_wait', + }, + doubleTap: { + label: '看别处', + narrate: '你把视线移开了。每个人都有自己的事,你也有你的。', + state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }), + next: 'h05_wait', + }, + }, + + h05_wait: { + delay: 6000, + narrate: '叫号机叫到了九十号。还有四个人。', + next: 'h05', + }, + + // 5. 窗外的光线 + h05: { + delay: 1000, + narrate: '窗外的光线变了。下午的阳光斜进来,在地板上画了一块长方形的亮。你注意到了。', + tap: { + label: '看着那块光', + narrate: '你盯着那块光看了一会儿。它在慢慢移动,非常慢,但是在动。时间在过。', + state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '看着地板上的光'] }), + next: 'h06_wait', + }, + doubleTap: { + label: '没注意', + narrate: '你闭上眼睛,想了想别的事。', + state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }), + next: 'h06_wait', + }, + }, + + h06_wait: { + delay: 4000, + narrate: '叫号机叫到了九十一号。', + next: 'h06', + }, + + // 6. 电话响了 + h06: { + delay: 1000, + narrate: '你的手机响了。是一个你不常打电话的人——是你家里的人。也许只是普通的问候。', + tap: { + label: '接了', + narrate: '你接了。对方说:你在哪啊,吃了吗。你说在外面,没事。就这样。挂掉之后候诊室里还是那么安静。', + state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '接了家人的电话'] }), + next: 'h07_wait', + }, + doubleTap: { + label: '没接', + narrate: '你看着屏幕亮了又暗。现在不是时候。等会儿再说。', + state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }), + next: 'h07_wait', + }, + }, + + h07_wait: { + delay: 5000, + narrate: '叫号机叫到了九十二号。', + next: 'h07', + }, + + // 7. 右边的陌生人 + h07: { + delay: 1000, + narrate: '你右边的人一直在看同一页的杂志,翻来翻去都是那页。你不确定她在看,还是只是拿着。', + tap: { + label: '看过去', + narrate: '你朝她看了一眼。你们的眼神碰了一下,然后都移开了。没有什么,就是两个陌生人在等待。', + state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '和陌生人的眼神'] }), + next: 'h08', + }, + doubleTap: { + label: '不看', + narrate: '你没有看。你在想等会儿检查结果会说什么。', + state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }), + next: 'h08', + }, + }, + + // 8. 叫号推进(自动) + h08: { + delay: 4000, + narrate: '九十三号。就差一个了。', + next: 'h09', + }, + + // 9. 叫到你了 + h09: { + delay: 2000, + narrate: '九十四号。是你。你站起来,整理了一下衣服,走向那扇门。', + tap: { + label: '走进去', + narrate: '你推开门走进去了。门关上了。等待结束了。', + state: s => s, + next: null, + }, + doubleTap: { + label: '再等一下', + narrate: '你停了一秒,深呼吸,然后走进去了。门关上了。', + state: s => s, + next: null, + }, + }, + }, + + getEnding: () => 'summary', + + endings: { + summary: { + narrate: s => { + const total = s.out + s.inside; + let t = `你等了很久。`; + if (s.out > s.inside) + t += `在这段时间里,你向外走了${s.out}次——看向别人,接了电话,留意窗外。`; + else if (s.inside > s.out) + t += `在这段时间里,你向内走了${s.inside}次——退回到自己里,先不管别的。`; + else + t += `你向外走了${s.out}次,也向内走了${s.inside}次,差不多一半一半。`; + if (s.moments.length > 0) + t += `你记住了:${s.moments.join(',')}。`; + t += '这就是你今天等待的方式。'; + return t; + }, + }, + }, +}; diff --git a/game/js/games/linxia.js b/game/js/games/linxia.js new file mode 100644 index 0000000..36bf1c5 --- /dev/null +++ b/game/js/games/linxia.js @@ -0,0 +1,556 @@ +// games/linxia.js — 《林夏》(重构版) +// 一部碎屏手机,一个北漂的夜晚,16条消息。 +// 操作:单击=接听/打开/回复,双击=拒接/忽略/不回。 +// 旁白全程解释状态,玩家不看屏幕也能明白自己在哪里。 + +const _T = '../audio/mp3/tts'; +const _MUS = '../audio/mp3/music'; +const _AMB = '../audio/mp3/ambience'; +const _SFX = '../audio/mp3/sfx'; + +const GAME_LINXIA = { + id: 'linxia', + intro: '今晚九点。你是林夏,二十四岁,在北京独居。手机屏幕碎了,但还能用。今晚有十六条消息,你来决定接谁,不接谁。单击接听或回复,双击拒接或忽略。', + first: 'msg_01', + bgm: `${_MUS}/lv11_bgm_m1_main_loop_v1.mp3`, + ambience: `${_AMB}/amb_apartment_night_01_v1.mp3`, + + initialState: { + mom_link: 0, // 与妈妈的连接深度 0-3 + he_back: null, // 阿哲是否回来 null/true/false + zhounan: 0, // 与周南互动深度 0-2 + self_heard: false, // 是否听了时光胶囊 + engage: 0, // 主动回应次数 + avoid: 0, // 回避次数 + }, + + scenes: { + + // ══════════════════ + // 01 · 妈妈来电 + // ══════════════════ + msg_01: { + narrate: '晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。', + tap: { + label: '接听', + audio: [`${_T}/lv11_2103_mom_call_01_wav_v1.mp3`], + narrate: '你接了。妈妈的声音从电话里传来,带着一点日常的关心,问你今天怎么样,吃了没有。你说还好。挂掉之后,房间里又安静了。', + state: s => ({ mom_link: s.mom_link + 1, engage: s.engage + 1 }), + next: 'msg_02_notify', + }, + doubleTap: { + label: '不接', + narrate: '铃声响了又停。妈妈没再打来。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_02_notify', + }, + }, + + // ══════════════════ + // 02 · 小美语音×3(教学关) + // ══════════════════ + msg_02_notify: { + delay: 1500, + narrate: '九点一刻。微信震了两下。小美给你发来三条语音消息。', + tap: { + label: '打开来听', + state: s => s, + next: 'msg_02_play', + }, + doubleTap: { + label: '先不看', + narrate: '你把手机屏幕朝下放着。消息还在那里,但你不想听。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_03', + }, + }, + msg_02_play: { + audio: [ + `${_T}/lv11_2115_xiaomei_voice_01_wav_v1.mp3`, + `${_T}/lv11_2115_xiaomei_voice_02_wav_v1.mp3`, + `${_T}/lv11_2115_xiaomei_voice_03_wav_v1.mp3`, + ], + narrate: '小美说了很多,大意是关于上次那件事,她一直想跟你说清楚,她说她只是太担心你了。最后她问:你最近还好吗?', + tap: { + label: '回复她', + narrate: '你回了消息。小美很快回说:谢谢你愿意回我。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_03', + }, + doubleTap: { + label: '不回了', + narrate: '你听完了,但没有回。消息就这样放着。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_03', + }, + }, + + // ══════════════════ + // 03 · 外卖小哥来电 + // ══════════════════ + msg_03: { + delay: 2000, + narrate: '九点二十分。来电话了,是一个陌生号码——是外卖小哥,说你的外卖到了,问你在哪层。', + tap: { + label: '接听', + audio: [`${_T}/lv11_2120_delivery_call_01_wav_v1.mp3`], + narrate: '你接了,告诉他楼层,外卖放门口就好。过了一会儿,手机震动,门铃响了,外卖到了。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_04_notify', + }, + doubleTap: { + label: '不接', + narrate: '你没接。不知道外卖最后怎么样了。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_04_notify', + }, + }, + + // ══════════════════ + // 04 · 阿哲语音 + // ══════════════════ + msg_04_notify: { + delay: 2500, + narrate: '九点半。微信震了一下。阿哲——你的前男友——给你发来一条语音。', + tap: { + label: '听', + state: s => s, + next: 'msg_04_play', + }, + doubleTap: { + label: '不看', + narrate: '你没有打开。阿哲的消息放在那里。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_05_notify', + }, + }, + msg_04_play: { + audio: [`${_T}/lv11_2130_azhe_voice_01_wav_v1.mp3`], + narrate: '阿哲说,他有一件东西落在你这里了,问你方便不方便他过来拿一下。语气很平,不像是有别的意思。', + tap: { + label: '回他', + narrate: '你回了条消息说可以。阿哲说谢谢,说到时候再联系。你放下手机,有点说不清的感觉。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_05_notify', + }, + doubleTap: { + label: '不回', + narrate: '你没有回。消息就这样。', + state: s => ({ avoid: s.avoid + 1, he_back: s.he_back === null ? false : s.he_back }), + next: 'msg_05_notify', + }, + }, + + // ══════════════════ + // 05 · HR王姐语音 + // ══════════════════ + msg_05_notify: { + delay: 2000, + narrate: '九点四十五分。HR王姐发来语音,是关于下个月调岗的事,问你有没有空聊一聊。', + tap: { + label: '听', + state: s => s, + next: 'msg_05_play', + }, + doubleTap: { + label: '明天再说', + narrate: '你把消息标记了已读,回头再说。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_06_notify', + }, + }, + msg_05_play: { + audio: [`${_T}/lv11_2145_hr_voice_01_wav_v1.mp3`], + narrate: '王姐说有个岗位的变动,你可能会受到影响,想提前跟你说一声,让你有个准备。语气是友善的,像是帮你。', + tap: { + label: '回她', + narrate: '你回了消息说谢谢告知,改天约时间聊。王姐说好的。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_06_notify', + }, + doubleTap: { + label: '不回', + narrate: '你没有回。这件事先放一放。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_06_notify', + }, + }, + + // ══════════════════ + // 06 · 安安·酒吧邀约 + // ══════════════════ + msg_06_notify: { + delay: 2000, + narrate: '晚上十点。安安发来语音,她说她们今晚在酒吧,问你要不要过来,说来了就是了,不用提前说。', + tap: { + label: '听完再说', + state: s => s, + next: 'msg_06_play', + }, + doubleTap: { + label: '不去', + narrate: '你没有打开消息。今晚不想动。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_07_notify', + }, + }, + msg_06_play: { + audio: [`${_T}/lv11_2200_anan_voice_01_wav_v1.mp3`], + narrate: '安安的声音很热闹,背景里有音乐。她说来嘛来嘛,好久没见了,就几个人,很轻松的那种。', + tap: { + label: '答应去', + narrate: '你回了条消息说行,等等到。安安说耶!你站起来换了件外套,但后来不知道为什么又坐下了。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_07_notify', + }, + doubleTap: { + label: '不去了', + narrate: '你回了消息说今晚状态不太好,下次吧。安安说没事,下次一定。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_07_notify', + }, + }, + + // ══════════════════ + // 07 · 寝室群×5 + // ══════════════════ + msg_07_notify: { + delay: 2000, + narrate: '晚上十点一刻。寝室群活了,连着震了好几下,是大学室友们在聊。你们已经很久没联系了。', + tap: { + label: '看看聊什么', + state: s => s, + next: 'msg_07_play', + }, + doubleTap: { + label: '静音', + narrate: '你把群消息静音了。不是今晚。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_08', + }, + }, + msg_07_play: { + audio: [ + `${_T}/lv11_2215_dorm_a_voice_01_wav_v1.mp3`, + `${_T}/lv11_2215_dorm_b_voice_01_wav_v1.mp3`, + `${_T}/lv11_2215_dorm_c_voice_01_wav_v1.mp3`, + `${_T}/lv11_2215_dorm_a_voice_02_wav_v1.mp3`, + `${_T}/lv11_2215_dorm_b_voice_02_wav_v1.mp3`, + ], + narrate: '她们在聊上周聚会的事,有人说想你了,有人问你最近怎么样,有人说快来啊。那种熟悉的语气,你有点想念。', + tap: { + label: '回一条', + narrate: '你发了一条消息,说最近还好,想你们了。群里一下子热闹起来,大家说你终于出现了。你看着那些消息,嘴角动了一下。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_08', + }, + doubleTap: { + label: '看着但不回', + narrate: '你看完了,但没有回复。你把手机放下。那种热闹是她们的,你在这边。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_08', + }, + }, + + // ══════════════════ + // 08 · 未知号码 + // ══════════════════ + msg_08: { + delay: 3000, + narrate: '晚上十点半。手机响了,来电显示:未知号码。铃声有点不一样,低沉的那种。', + tap: { + label: '接听', + audio: [`${_SFX}/sfx_breathing_unknown_v1.mp3`], + narrate: '你接了。里面没有声音,只有轻微的呼吸声,然后断了。你不知道是谁,心跳加快了一下。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_09_notify', + }, + doubleTap: { + label: '不接', + narrate: '未知号码,不接。铃声停了。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_09_notify', + }, + }, + + // ══════════════════ + // 09 · 小美醉了·深夜语音×4 + // ══════════════════ + msg_09_notify: { + delay: 3000, + bgm: `${_MUS}/lv11_bgm_m2_suspense_v1.mp3`, + narrate: '十点四十五分。小美又发来消息,连续四条。语音很长,而且发送时间是一条接一条。', + tap: { + label: '听', + state: s => s, + next: 'msg_09_play', + }, + doubleTap: { + label: '先不听', + narrate: '消息放那里,你没有打开。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_10_notify', + }, + }, + msg_09_play: { + audio: [ + `${_T}/lv11_2245_xiaomei_voice_01_seg1_wav_v1.mp3`, + `${_T}/lv11_2245_xiaomei_voice_01_seg2_wav_v1.mp3`, + `${_T}/lv11_2245_xiaomei_voice_01_seg3_wav_v1.mp3`, + `${_T}/lv11_2245_xiaomei_voice_01_seg4_wav_v1.mp3`, + ], + narrate: '小美喝多了,说话有点不清楚,但每一句都很认真。她说她一直有件事想跟你说:那年她告诉了别人,是她不对,她知道,她一直很愧疚,她说她怕你恨她。', + tap: { + label: '回她', + narrate: '你打了几个字发过去。小美隔了很久才回,说谢谢你,然后说她要先睡了,明天聊。你把手机放下。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_10_notify', + }, + doubleTap: { + label: '今晚不回', + narrate: '你没有回。这件事太重了,不是今晚能说清楚的。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_10_notify', + }, + }, + + // ══════════════════ + // 10 · 妈妈文字消息 + // ══════════════════ + msg_10_notify: { + delay: 2500, + narrate: '深夜十一点刚过。妈妈发来一条文字消息,就五个字:睡了吗,夏夏。', + tap: { + label: '回妈妈', + narrate: '你回了:还没呢,妈你睡吧。妈妈说好,早点睡,然后发了个晚安的表情。你盯着那个表情看了一会儿。', + state: s => ({ mom_link: s.mom_link + 1, engage: s.engage + 1 }), + next: 'msg_11', + }, + doubleTap: { + label: '不回', + narrate: '你没有回妈妈的消息。她那边大概以为你已经睡了。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_11', + }, + }, + + // ══════════════════ + // 11 · 阿哲来电(关键分支) + // ══════════════════ + msg_11: { + delay: 2000, + narrate: '十一点一刻。手机响了,专属铃声——是阿哲打来的。', + tap: { + label: '接听', + audio: [`${_T}/lv11_2315_azhe_call_01_wav_v1.mp3`], + narrate: '阿哲说他就在附近,问你方不方便他上来取一下那件东西。他的声音平静,但停顿了一下。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_11_choice', + }, + doubleTap: { + label: '拒接', + narrate: '铃声停了。阿哲没再打来。', + state: s => ({ avoid: s.avoid + 1, he_back: false }), + next: 'msg_12_notify', + }, + }, + msg_11_choice: { + narrate: '他在等你说行不行。', + tap: { + label: '让他来', + narrate: '你说好,上来吧。阿哲说谢谢,说马上到。你放下手机,在房间里站了一会儿。', + state: s => ({ he_back: true }), + next: 'msg_12_notify', + }, + doubleTap: { + label: '不方便', + narrate: '你说今晚不太方便。阿哲沉默了一秒,说行,那之后再说。挂掉了。', + state: s => ({ he_back: false }), + next: 'msg_12_notify', + }, + }, + + // ══════════════════ + // 12 · 周南·第一条语音 + // ══════════════════ + msg_12_notify: { + delay: 3000, + narrate: '十一点半。一个名字出现在屏幕上:周南。你有三年没联系了,他突然发来一条语音。', + tap: { + label: '听', + state: s => s, + next: 'msg_12_play', + }, + doubleTap: { + label: '不打开', + narrate: '你没有打开周南的消息。三年了,不急这一会儿。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_13_notify', + }, + }, + msg_12_play: { + audio: [`${_T}/lv11_2330_zhounan_voice_01_wav_v1.mp3`], + narrate: '周南说:林夏你好,是我周南,不知道你还记不记得,就是突然想起你,也不知道你还在不在这个号,就发一条。他的声音还是老样子,只是低沉了一些。', + tap: { + label: '回他', + narrate: '你回了:记得,你好啊。消息发出去了,你盯着那两个字,觉得说多说少都不对,就这样。', + state: s => ({ zhounan: s.zhounan + 1, engage: s.engage + 1 }), + next: 'msg_13_notify', + }, + doubleTap: { + label: '不回', + narrate: '你听完了,没有回。也许三年的空白不是一条消息能填上的。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_13_notify', + }, + }, + + // ══════════════════ + // 13 · 周南·长语音×4 + // ══════════════════ + msg_13_notify: { + delay: 2500, + bgm: `${_MUS}/lv11_bgm_m3_zhounan_v1.mp3`, + narrate: '深夜十一点四十五分。周南又发来四条,比刚才长很多。', + tap: { + label: '听', + state: s => s, + next: 'msg_13_play', + }, + doubleTap: { + label: '不听了', + narrate: '你把手机放到一边了。今晚已经够多了。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_14', + }, + }, + msg_13_play: { + audio: [ + `${_T}/lv11_2345_zhounan_voice_01_wav_v1.mp3`, + `${_T}/lv11_2345_zhounan_voice_02_wav_v1.mp3`, + `${_T}/lv11_2345_zhounan_voice_03_wav_v1.mp3`, + `${_T}/lv11_2345_zhounan_voice_04_wav_v1.mp3`, + ], + narrate: '周南说了他这十年:换了几个城市,做了几份工作,有一段感情,然后结束了。他说他今晚喝了一点酒,就想到你,想到你们高中的那些事,说他一直不知道当年为什么就断了联系。然后他问:你在北京过得好吗?', + tap: { + label: '认真回他', + narrate: '你想了很久,然后把这段时间的事告诉了周南。说不上好不好,就是在过。他说谢谢你告诉我这些。你们聊了很久。', + state: s => ({ zhounan: s.zhounan + 1, engage: s.engage + 1 }), + next: 'msg_14', + }, + doubleTap: { + label: '说还好', + narrate: '你回了说还好,然后问了他现在在哪里。你们就这样聊着,没有太深入,但很平静。', + state: s => ({ engage: s.engage + 1 }), + next: 'msg_14', + }, + }, + + // ══════════════════ + // 14 · 妈妈·第二次来电 + // ══════════════════ + msg_14: { + delay: 3000, + narrate: '凌晨零点一刻。妈妈又打来电话了,这么晚——她还没睡。', + tap: { + label: '接听', + audio: [`${_T}/lv11_0015_mom_call_01_wav_v1.mp3`], + narrate: s => s.mom_link >= 1 + ? '你接了。妈妈说:我就是想再听到你的声音。你们聊了一会儿,说了一些很日常的话,但你都记住了。' + : '你接了。妈妈说:没事,就是问问你睡了没。你说没呢,妈你怎么还不睡。她说想听你的声音。', + state: s => ({ mom_link: s.mom_link + 1, engage: s.engage + 1 }), + next: 'msg_15', + }, + doubleTap: { + label: '拒接', + narrate: '太晚了,挂掉了。妈妈没有再打来。', + state: s => ({ avoid: s.avoid + 1 }), + next: 'msg_15', + }, + }, + + // ══════════════════ + // 15 · 时光胶囊 + // ══════════════════ + msg_15: { + delay: 2000, + narrate: '凌晨零点半。手机弹出一个系统通知:三年前的今天,你给三年后的自己录了一条语音,今天到了。', + tap: { + label: '听这条语音', + audio: [ + `${_T}/lv11_sys_memo_01_wav_v1.mp3`, + `${_T}/lv11_0030_self3y_voice_01_wav_v1.mp3`, + ], + narrate: '你听到了三年前的自己的声音。她说:如果你在听这条消息,说明你撑过来了。不管现在怎么样,你撑过来了。然后她停顿了很久,说:辛苦了。', + state: s => ({ self_heard: true, engage: s.engage + 1 }), + next: 'msg_16', + }, + doubleTap: { + label: '不听了', + narrate: '你把那个通知划掉了。也许不是现在。', + state: s => ({ self_heard: false, avoid: s.avoid + 1 }), + next: 'msg_16', + }, + }, + + // ══════════════════ + // 16 · 结局触发点 + // ══════════════════ + msg_16: { + delay: 3000, + narrate: s => s.mom_link >= 2 + ? '凌晨两点。妈妈发来一段很长的语音,她说她一直有些话想说给你听。' + : '凌晨两点。房间很安静,窗外偶尔有车声。今晚就这样了。', + tap: { + label: '听妈妈说', + audio: s => s.mom_link >= 2 ? [ + `${_T}/lv11_0200_mom_voice_01_seg1_wav_v1.mp3`, + `${_T}/lv11_0200_mom_voice_01_seg2_wav_v1.mp3`, + `${_T}/lv11_0200_mom_voice_01_seg3_wav_v1.mp3`, + `${_T}/lv11_0200_mom_voice_01_seg4_wav_v1.mp3`, + `${_T}/lv11_0200_mom_voice_01_seg5_wav_v1.mp3`, + `${_T}/lv11_0200_mom_voice_01_seg6_wav_v1.mp3`, + `${_T}/lv11_0200_mom_voice_01_seg7_wav_v1.mp3`, + `${_T}/lv11_0200_mom_voice_01_seg8_wav_v1.mp3`, + ] : [], + narrate: s => s.mom_link >= 2 + ? '妈妈说了很多——关于你小时候,关于她一直担心的事,关于她说不出口但一直想说的话。你把手机贴在耳边,听完了。' + : '你就这样坐着,今晚的声音慢慢变少了。', + state: s => s, + next: null, + }, + doubleTap: { + label: '就这样了', + narrate: '你把手机放到床头,闭上眼睛。今晚就这样了。', + state: s => s, + next: null, + }, + }, + }, + + getEnding: s => { + if (s.he_back === true) return 'return'; // 优先级1 + if (s.mom_link >= 2 && s.engage >= 4) return 'link'; // 优先级2 + if (s.zhounan >= 2) return 'past'; // 优先级3 + if (s.self_heard) return 'self'; // 优先级4 + return 'island'; // 兜底 + }, + + endings: { + return: { + narrate: '《回归》。阿哲来了,或者你们说好了见面。不管那个门开不开,今晚你做了一个决定。', + }, + link: { + narrate: '《连接》。今晚你和妈妈说了话,不止一次。那种联系很细,但是在。也许明天你会想打电话回去。', + }, + past: { + narrate: '《旧路》。周南在深夜找到了你,你们聊了很久。那条从高中就断掉的线,今晚接上了一点点。', + }, + self: { + narrate: '《自省》。今晚你听了三年前自己的声音。她说辛苦了,你接受了这句话。这不算小事。', + }, + island: { + narrate: '《孤岛》。今晚大部分的消息,你都没有回应。你一个人度过了这个夜晚。这也是一种选择,你心里清楚。', + }, + }, +}; diff --git a/game/js/games/shouye.js b/game/js/games/shouye.js new file mode 100644 index 0000000..c7af253 --- /dev/null +++ b/game/js/games/shouye.js @@ -0,0 +1,175 @@ +// games/shouye.js — 《守夜》 +// 夜班保安,监控室,五个小时。 +// 单击=去查那个声音,双击=按兵不动。 +// 结局由你调查了什么决定。 + +const GAME_SHOUYE = { + id: 'shouye', + intro: '凌晨零点。你是这栋楼的夜班保安,独自坐在一楼监控室。你不能离开座位,但你有一部对讲机和一部电话。今晚五点交班。单击,去查那个声音。双击,按兵不动。', + first: 's01', + bgm: null, + ambience: '../audio/mp3/ambience/amb_apartment_night_01_v1.mp3', + initialState: { + investigated: [], + ignored: [], + family_called: false, + saw_dawn: false, + missed_fight: true, // 默认"没管四楼争吵" + }, + + scenes: { + // ── 00:10 地下室水声 ── + s01: { + delay: 2000, + narrate: '凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。', + tap: { + label: '打电话下去查', + narrate: '你拨了地下室的内线。没人接。你下去看了一眼,是一根管子在漏水,用旧布堵住了。回到监控室,零点二十三分。', + state: s => ({ investigated: [...s.investigated, '地下室漏水'], ignored: s.ignored }), + next: 's02', + }, + doubleTap: { + label: '先不管', + narrate: '你听了一会儿。应该不碍事。你把注意力放回到监控屏幕上。', + state: s => ({ investigated: s.investigated, ignored: [...s.ignored, '地下室水声'] }), + next: 's02', + }, + }, + + // ── 00:47 四楼争吵 ── + s02: { + delay: 3000, + narrate: '零点四十七分。四楼传来争吵声,一男一女,断断续续,能听出来语气很激动,但听不清说什么。', + tap: { + label: '打电话上去', + narrate: '你拨了四楼的号码。男的接了,说没事,就是聊天,让你别担心。声音还是有点紧,但他们关掉了。你记下来了:四楼,零点五十二分,双方均在场。', + state: s => ({ investigated: [...s.investigated, '四楼争吵'], ignored: s.ignored, missed_fight: false }), + next: 's03', + }, + doubleTap: { + label: '不管,私事', + narrate: '家务事,你管不着。你转过身去继续盯着屏幕。争吵后来停了,你不知道是什么时候。', + state: s => ({ investigated: s.investigated, ignored: [...s.ignored, '四楼争吵'], missed_fight: true }), + next: 's03', + }, + }, + + // ── 01:17 三楼脚步(老陈)── + s03: { + delay: 3000, + narrate: '凌晨一点十七分。三楼有脚步声,很轻,走走停停。监控记录:三楼只有七零二亮着灯,住户是老陈,八十岁,独居。', + tap: { + label: '打电话问问', + narrate: '电话通了,老陈说他在找眼镜,眼镜掉床底下了。你说没事我就是例行确认一下。他说谢谢你小伙子,然后你们聊了几句,关于他年轻时候也做过工程。你挂掉电话,一点二十八分。', + state: s => ({ investigated: [...s.investigated, '三楼老陈'], ignored: s.ignored }), + next: 's04', + }, + doubleTap: { + label: '应该没事', + narrate: '老人家睡不好,很正常。你记了一笔,继续监控。脚步声后来停了。', + state: s => ({ investigated: s.investigated, ignored: [...s.ignored, '三楼脚步'] }), + next: 's04', + }, + }, + + // ── 02:03 门铃无应答 ── + s04: { + delay: 4000, + narrate: '凌晨两点零三分。大门门铃响了,不是访客系统,是有人在外面按了手动铃,按了两下,然后停了。没有人应答。监控画面:门口没有人了。', + tap: { + label: '出去看看', + narrate: '你走到门口,什么都没有,只有一个快递包裹靠在墙边,大概是外卖或者什么。也许是送错了。你把包裹推到角落,拍了张照,两点一刻回到座位。', + state: s => ({ investigated: [...s.investigated, '门铃'], ignored: s.ignored }), + next: 's05', + }, + doubleTap: { + label: '不是你管的事', + narrate: '无应答,无入侵,记录在案,按流程忽略。你回到屏幕前。', + state: s => ({ investigated: s.investigated, ignored: [...s.ignored, '门铃'] }), + next: 's05', + }, + }, + + // ── 02:55 你打盹了(自动)── + s05: { + delay: 4000, + narrate: s => { + const base = '凌晨两点五十五分。你不知道从什么时候开始,眼皮变得很重……你睡着了一会儿。'; + if (s.missed_fight) + return base + '醒来的时候,你看到监控里,四楼门口放着一个箱子,不知道是谁的,不知道什么时候放的。'; + return base + '醒来的时候,监控里一切正常。你记录:三点十二分,短暂失守。'; + }, + next: 's06', + }, + + // ── 03:30 家人来电 ── + s06: { + delay: 3000, + narrate: '凌晨三点半。你的私人手机响了。是家里的人打来的。这么晚打来——可能是什么事,也可能只是弄错时间了。', + tap: { + label: '接了', + narrate: '是你妈,她说睡不着,看见你今天发的朋友圈,就想打个电话。你们聊了十分钟,说了些不重要的事。挂掉之后,监控室比之前暖了一点点。', + state: s => ({ ...s, family_called: true }), + next: 's07', + }, + doubleTap: { + label: '没接', + narrate: '这么晚不方便。你把手机调成静音,准备待会儿发条消息。你没有发。', + state: s => ({ ...s, family_called: false }), + next: 's07', + }, + }, + + // ── 04:12 屋顶声音 ── + s07: { + delay: 4000, + narrate: '凌晨四点十二分。屋顶传来动静,断断续续,不像是机械设备,更像是有什么东西在走动。', + tap: { + label: '上去看', + narrate: '你拿着手电上了顶层,推开屋顶的铁门,风吹过来,你看见了天边刚刚开始发白的颜色——还不是黎明,但是快了。然后你看见了一只流浪猫,坐在水箱上看着你。你们看了对方一会儿,然后你下来了。你是今晚唯一上了屋顶的人。', + state: s => ({ ...s, investigated: [...s.investigated, '屋顶'], saw_dawn: true }), + next: 's08', + }, + doubleTap: { + label: '可能是猫', + narrate: '你记了一笔,继续监控。动静后来停了。', + state: s => ({ ...s, ignored: [...s.ignored, '屋顶声音'], saw_dawn: false }), + next: 's08', + }, + }, + + // ── 05:00 交班(自动→结局)── + s08: { + delay: 5000, + narrate: '五点整。接班的人进来了,你们交接了几句,递了记录本,你背上包走出去。外面已经有一点点亮了。', + next: null, + }, + }, + + getEnding: s => { + if (s.saw_dawn) return 'dawn'; + const n = s.investigated.length; + if (n >= 4 && s.family_called) return 'solid'; + if (n <= 1 && !s.family_called) return 'detached'; + if (s.missed_fight) return 'oversight'; + return 'ordinary'; + }, + + endings: { + dawn: { + narrate: '《你看到了什么》。今晚你一个人上了屋顶,你看到了那只猫,你看到了天边开始变色。只有你看到了。这算不上什么,但只有你知道。', + }, + solid: { + narrate: '《平凡守夜》。你今晚查了应该查的,接了应该接的电话。没有大事,没有遗漏。这栋楼平安了一夜,有一部分是因为你在。', + }, + detached: { + narrate: '《这不是我的事》。你今晚保持了距离。声音来了,你记录,你不动。你守住了一夜,但不知道楼里发生了什么。这也是一种守夜的方式。', + }, + oversight: { + narrate: '《疏忽》。你今晚漏掉了四楼的事。那个女人在凌晨两点多拎着箱子离开了,你没有注意到。也许和你没有关系。也许有。你不知道。', + }, + ordinary: { + narrate: '《普通的夜班》。你做了你能做的,漏掉了一些,照顾到了一些。和所有普通的夜班一样。', + }, + }, +}; diff --git a/game/js/games/yinian.js b/game/js/games/yinian.js new file mode 100644 index 0000000..10ce689 --- /dev/null +++ b/game/js/games/yinian.js @@ -0,0 +1,185 @@ +// games/yinian.js — 《一念》 +// 内心法庭。七个声音,一个决定。 +// 单击=接纳这个声音,双击=让它走。 +// 结局由接纳了哪些声音决定。 + +const GAME_YINIAN = { + id: 'yinian', + intro: '你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。七个声音在等着和你说话。单击,让它说完。双击,让它走。', + first: 'v01', + bgm: null, + ambience: '../audio/mp3/ambience/amb_apartment_night_01_v1.mp3', + initialState: { + accepted: [], // 接纳的声音 + rejected: [], // 放走的声音 + // 关键标记 + kept_fear: false, + kept_longing: false, + kept_apology: false, + kept_past: false, + }, + + scenes: { + // ── 第一个声音:三年前的自己 ── + v01: { + delay: 1000, + narrate: '第一个声音来了。是三年前的你。那时候你刚做了一个选择,你觉得那是对的。那时候的你说:我们当时想要的,你还记得吗?', + tap: { + label: '听它说完', + narrate: '你让三年前的自己说完了。她说,你当时很想要那件事。你知道那是真的。', + state: s => ({ accepted: [...s.accepted, '三年前的自己'], rejected: s.rejected, kept_past: true }), + next: 'v02', + }, + doubleTap: { + label: '让它走', + narrate: '三年前的声音淡去了。你选择了不看它。', + state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '三年前的自己'], kept_past: false }), + next: 'v02', + }, + }, + + // ── 第二个声音:一个老朋友的留言 ── + v02: { + delay: 1000, + narrate: '第二个声音。是一个你很久没联系的朋友,她留了条语音。她说:你最近怎么样,我有点担心你。很久了,你一直没回。', + tap: { + label: '听下去', + narrate: '你把这份担心收进来了。她还在关心你,这件事是真实的。', + state: s => ({ accepted: [...s.accepted, '朋友的担心'], rejected: s.rejected }), + next: 'v03', + }, + doubleTap: { + label: '让它走', + narrate: '留言消失了。你没有听完。', + state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '朋友的担心'] }), + next: 'v03', + }, + }, + + // ── 第三个声音:关于钱的恐惧 ── + v03: { + delay: 1000, + narrate: '第三个声音。是关于钱的恐惧。它说:你知道你现在能撑多久吗?如果这件事不成,你怎么办?它问得很具体,很冷静。', + tap: { + label: '正视它', + narrate: '你承认了:这份恐惧是真实的。你带着它,而不是假装它不存在。', + state: s => ({ accepted: [...s.accepted, '对钱的恐惧'], rejected: s.rejected, kept_fear: true }), + next: 'v04', + }, + doubleTap: { + label: '先不管它', + narrate: '恐惧的声音淡下去了。你知道它还在,但今晚先不看。', + state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '对钱的恐惧'], kept_fear: false }), + next: 'v04', + }, + }, + + // ── 第四个声音:关于孤独的渴望 ── + v04: { + delay: 1000, + narrate: '第四个声音。是一种渴望——想要被人理解,想要有人在身边,想要不那么孤独。它来得轻,但是很深。', + tap: { + label: '承认它', + narrate: '你承认了这份渴望。它是真实的,不是软弱。', + state: s => ({ accepted: [...s.accepted, '对连接的渴望'], rejected: s.rejected, kept_longing: true }), + next: 'v05', + }, + doubleTap: { + label: '让它走', + narrate: '渴望漂走了。你告诉自己,一个人也没什么。', + state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '对连接的渴望'], kept_longing: false }), + next: 'v05', + }, + }, + + // ── 第五个声音:一个未说出口的道歉 ── + v05: { + delay: 1000, + narrate: '第五个声音。是一个你一直没有说出口的道歉。你知道是对谁的,你知道说的是什么。这个声音只是在等你。', + tap: { + label: '说出来', + narrate: '你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。', + state: s => ({ accepted: [...s.accepted, '那声道歉'], rejected: s.rejected, kept_apology: true }), + next: 'v06', + }, + doubleTap: { + label: '还没准备好', + narrate: '道歉的声音还在那里。你没有说。它等着。', + state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '那声道歉'], kept_apology: false }), + next: 'v06', + }, + }, + + // ── 第六个声音:关于那个决定 ── + v06: { + delay: 1000, + narrate: '第六个声音。是今晚那个决定本身。它把两种可能都摆在你面前,不说话,只是等着你看清楚。', + tap: { + label: '看清楚它', + narrate: '你看了。两条路都有代价,你知道了。这不让选择变得更容易,但让它变得更真实。', + state: s => ({ accepted: [...s.accepted, '那个决定本身'], rejected: s.rejected }), + next: 'v07', + }, + doubleTap: { + label: '还不想看', + narrate: '你把眼睛闭上了。还没准备好看那两条路。', + state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '那个决定本身'] }), + next: 'v07', + }, + }, + + // ── 第七个声音:现在的自己 ── + v07: { + delay: 2000, + narrate: s => { + const n = s.accepted.length; + if (n >= 5) + return '第七个声音,是现在的你。她说:你今晚接住了很多,你没有回避它们。这需要力气。'; + if (n <= 1) + return '第七个声音,是现在的你。她沉默了很久,然后说:你把它们都推走了。也许你需要的不是答案,是喘息。'; + if (s.kept_fear && !s.kept_longing) + return '第七个声音,是现在的你。她说:你把恐惧留下了,但把渴望推走了。你知道这意味着什么吗?'; + return '第七个声音,是现在的你。她说:你接住了一些,放走了一些。这就是你今晚的状态。'; + }, + tap: { + label: '听下去', + narrate: '你听完了现在的自己说话。她说:不管你今晚做什么决定,你都已经在这里待过了。这算数。', + state: s => ({ accepted: [...s.accepted, '现在的自己'], rejected: s.rejected }), + next: null, + }, + doubleTap: { + label: '就这样', + narrate: '你没有听完。你觉得你已经知道了你需要知道的。', + state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '现在的自己'] }), + next: null, + }, + }, + }, + + getEnding: s => { + if (s.kept_apology) return 'reconcile'; + const n = s.accepted.length; + if (n >= 5) return 'accept'; + if (n <= 1) return 'release'; + if (s.kept_fear && !s.kept_longing) return 'stuck'; + return 'between'; + }, + + endings: { + accept: { + narrate: '《接纳》。你今晚接住了很多,你没有选择只是轻装。也许你的决定,就是带着这些往前走。', + }, + release: { + narrate: '《出发》。你今晚把大部分都放走了。你想要的也许是轻。轻装,也是一种勇气。', + }, + stuck: { + narrate: '《困住》。你接受了恐惧,但推走了渴望。你知道风险,但忘了你想要的是什么。这个决定,也许需要再等一等。', + }, + reconcile: { + narrate: '《和解》。你说出了那声道歉。不管今晚的决定是什么,你和自己和解了一点点。这很重要。', + }, + between: { + narrate: '《悬在中间》。你接住了一些,放走了一些。你还悬在两个答案之间。也许这就是你今晚真实的状态。', + }, + }, +}; diff --git a/game/js/games/yisheng.js b/game/js/games/yisheng.js new file mode 100644 index 0000000..fc8f9b8 --- /dev/null +++ b/game/js/games/yisheng.js @@ -0,0 +1,308 @@ +// games/yisheng.js — 《遗声》 +// 陌生人的手机,十五条录音。 +// 单击=听这条,双击=跳过。 +// 跳过就永远不知道这条说了什么。 +// 结局由你听了多少、听了哪些决定。 + +const GAME_YISHENG = { + id: 'yisheng', + intro: '你收到了一部手机。手机的主人去世了一个月前。他留下了很多录音,按时间排列。你不认识他。或者你以为你不认识他。单击,听这条录音。双击,跳过,永远不知道这条说了什么。', + first: 'r01', + bgm: null, + ambience: null, + initialState: { + heard: [], + skipped: [], + heard_critical: 0, // 听了几条关键录音(r10-r14) + unlocked_last: false, + }, + + scenes: { + // ── 第1条:三月五日 ── + r01: { + narrate: '第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。', + tap: { + label: '听', + narrate: '他说:今天买了一袋橙子,是脐橙,比上次的甜很多。建议大家有机会可以试试。就这些。录音结束。', + state: s => ({ heard: [...s.heard, '买橙子'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r02', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第一条。你永远不会知道这条说了什么了。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第一条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r02', + }, + }, + + // ── 第2条:工资日 ── + r02: { + narrate: '第二条录音。三月十二日,下午。时长:三十八秒。文件名:发工资了。', + tap: { + label: '听', + narrate: '他说:今天发工资了。然后列了他这个月的支出清单,很详细——房租、电费、手机月供、还有给他妈买了一件外套。他说最后剩了不多,但够。录音结束。', + state: s => ({ heard: [...s.heard, '发工资了'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r03', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第二条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第二条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r03', + }, + }, + + // ── 第3条:工作烦心事 ── + r03: { + narrate: '第三条录音。三月十八日,晚上。时长:一分零七秒。文件名:说说工作的事。', + tap: { + label: '听', + narrate: '他说他们组里来了个新同事,总是在会议上抢着发言,说了很多他本来准备说的话。他说他当时没说话,他不确定那是不是对的选择。他在录音里问自己:我是懦弱了,还是留有余地?他没有答案。录音结束。', + state: s => ({ heard: [...s.heard, '工作的烦恼'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r04', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第三条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第三条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r04', + }, + }, + + // ── 第4条:和同事吵架 ── + r04: { + narrate: '第四条录音。四月二日。时长:四十九秒。文件名:吵了一架。', + tap: { + label: '听', + narrate: '他说他今天和那个新同事正面冲突了,是在走廊,两个人都没控制住。他说他说了一句话,事后觉得说得太重了。他在录音里把那句话又说了一遍,想听听是不是真的太重。录音里他沉默了一会儿,然后说:是的,是太重了。录音结束。', + state: s => ({ heard: [...s.heard, '吵了一架'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r05', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第四条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第四条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r05', + }, + }, + + // ── 第5条:电影 ── + r05: { + narrate: '第五条录音。四月十五日,深夜。时长:二十六秒。文件名:电影。', + tap: { + label: '听', + narrate: '他说他去看了一部很老的电影,黑白的,在一个小影院,观众只有六个人。他说他中途哭了,但他不确定是因为电影,还是因为电影院里那种空荡荡的感觉。录音结束。', + state: s => ({ heard: [...s.heard, '老电影'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r06', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第五条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第五条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r06', + }, + }, + + // ── 第6条:小林 ── + r06: { + narrate: '第六条录音。五月三日。时长:一分二十秒。文件名:小林的事。', + tap: { + label: '听', + narrate: '他开始讲一个叫小林的朋友。他说小林是他认识最久的人,从小一起长大的,后来各自去了不同的城市,联系越来越少。他说他最近一直在想,那种联系的稀薄,是谁的责任?还是说这就是长大的代价?录音结束。', + state: s => ({ heard: [...s.heard, '小林的事'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r07', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第六条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第六条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r07', + }, + }, + + // ── 第7条:小林的一句话 ── + r07: { + narrate: '第七条录音。五月二十日。时长:三十三秒。文件名:小林说的。', + tap: { + label: '听', + narrate: '他说小林打电话来了。小林在电话里说了一句话,让他很难受。小林说:感觉你现在不太一样了,变得……不知道,挺难接近的。他说他当时说了没什么,你想多了。他说他那时候应该说:我只是最近状态不好。但他没说。录音结束。', + state: s => ({ heard: [...s.heard, '小林说的话'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r08', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第七条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第七条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r08', + }, + }, + + // ── 第8条:感情结束了 ── + r08: { + narrate: '第八条录音。六月八日,凌晨。时长:两分十四秒。文件名:结束了。', + tap: { + label: '听', + narrate: '他说一段感情结束了,他没有细说是谁,说了有多久,只说:结束了,是我提的,我觉得这是对的,但今晚我睡不着。他说他在厨房坐着,泡了杯茶,但茶凉了也没喝。他说:我只是想把今晚的感觉记下来,不想让它消失。录音结束。', + state: s => ({ heard: [...s.heard, '感情结束了'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r09', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第八条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第八条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r09', + }, + }, + + // ── 第9条:是不是我的错 ── + r09: { + narrate: '第九条录音。六月二十日。时长:一分五十秒。文件名:在反思。', + tap: { + label: '听', + narrate: '他说他一直在想那段感情里他做错了什么。他列了很多条,每一条都很具体。然后他说:但是我说不清,这些错是因为我是这样的人,还是因为那时候我在经历别的事情,所以才这样。他说:这个问题很重要,但我没有答案。录音结束。', + state: s => ({ heard: [...s.heard, '反思'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r10', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第九条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第九条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r10', + }, + }, + + // ── 第10条(关键):有人问起你 ── + r10: { + narrate: '第十条录音。七月四日。时长:四十二秒。文件名:有人问起你。', + tap: { + label: '听', + narrate: '他说:今天有人跟我提到了你的名字。我以为我早就忘了,但我发现我还记得很多细节。他没有解释"你"是谁。他说:我觉得我应该把这件事录下来,因为我发现我不想忘记。录音结束。', + state: s => ({ heard: [...s.heard, '有人问起你'], skipped: s.skipped, heard_critical: s.heard_critical + 1, unlocked_last: s.unlocked_last }), + next: 'r11', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第十条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r11', + }, + }, + + // ── 第11条(关键):他查了你 ── + r11: { + narrate: '第十一条录音。七月十一日。时长:一分零八秒。文件名:我查了一下。', + tap: { + label: '听', + narrate: '他说:我查了你现在在哪里,在做什么。不是恶意,就是想知道。他说他找到了一些信息,知道了你现在的大致状态。他说:你好像过得还不错,我很高兴,也有一点……他停了,然后说:没什么。录音结束。', + state: s => ({ heard: [...s.heard, '他查了你'], skipped: s.skipped, heard_critical: s.heard_critical + 1, unlocked_last: s.unlocked_last }), + next: 'r12', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第十一条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十一条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r12', + }, + }, + + // ── 第12条(关键):如果你听到 ── + r12: { + narrate: '第十二条录音。八月二日。时长:五十七秒。文件名:给不认识我的人(草稿)。', + tap: { + label: '听', + narrate: '他说:如果你听到这条,你肯定不认识我。我在想,把这些录音留给一个陌生人,有什么意义。然后他说:也许是因为我希望有人知道我活过,而不是认识我的人,而是真正陌生的人——你不需要对我有感情,你只是听了。录音结束。', + state: s => ({ heard: [...s.heard, '给陌生人的草稿'], skipped: s.skipped, heard_critical: s.heard_critical + 1, unlocked_last: s.unlocked_last }), + next: 'r13', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第十二条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十二条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r13', + }, + }, + + // ── 第13条(关键):一件事 ── + r13: { + narrate: '第十三条录音。九月一日。时长:两分三十八秒。文件名:我有一件事想说。', + tap: { + label: '听', + narrate: '他深吸一口气,说:有一件事我从没对任何人说过。他停了很久,然后说:我有时候觉得活着的意义我不太找得到,不是说我要做什么,只是我不知道我在这里有什么用。他说:我知道这句话很沉,但我必须把它说出来,因为放在心里太久了。然后他说:我今天状态还可以,就是今天有这个力气把它说出来。录音结束。', + state: s => ({ heard: [...s.heard, '那件事'], skipped: s.skipped, heard_critical: s.heard_critical + 1, unlocked_last: s.unlocked_last }), + next: 'r14', + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第十三条。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十三条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }), + next: 'r14', + }, + }, + + // ── 第14条(关键):最后一条 ── + r14: { + narrate: '第十四条录音。十月十五日,录制时间是他去世前三天。时长:三分二十秒。文件名:最后一条,我想讲完整。', + tap: { + label: '听', + narrate: '他说:我知道这是最后一条了。我不是悲观,我只是知道。他说:我想对听这些录音的人说,我不后悔我活过的大多数时候。橙子是甜的,老电影是值得哭的,小林的声音我还记得,那个人,不管怎样,我不后悔爱过。他说:如果你听了很多条,谢谢你。你是陌生人,但你陪着我说完了。录音结束。', + state: s => ({ + heard: [...s.heard, '最后一条'], + skipped: s.skipped, + heard_critical: s.heard_critical + 1, + unlocked_last: s.heard.length >= 9, + }), + next: s => s.heard.length >= 9 ? 'r15' : null, + }, + doubleTap: { + label: '跳过', + narrate: '你跳过了第十四条,也就是最后一条。手机里没有更多录音了。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十四条'], heard_critical: s.heard_critical, unlocked_last: false }), + next: null, + }, + }, + + // ── 第15条(隐藏):给不认识我的人 ── + r15: { + delay: 2000, + narrate: '还有一条录音,没有标日期。文件名:给不认识我的人(正式版)。这条需要你听了很多条才能解锁。你解锁了。', + tap: { + label: '听', + narrate: '他说:你好。你不认识我,我也不认识你。但你坐在那里,听完了我这些年说的话。我不知道你是谁,我不知道你们那边现在是白天还是夜晚。我只是想说:谢谢你愿意听一个陌生人说话。这很重要,比你知道的还要重要。录音结束。这是最后一条了。', + state: s => ({ heard: [...s.heard, '给不认识我的人'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: true }), + next: null, + }, + doubleTap: { + label: '跳过', + narrate: '你连最后这条也跳过了。', + state: s => ({ heard: s.heard, skipped: [...s.skipped, '最后一条'], heard_critical: s.heard_critical, unlocked_last: false }), + next: null, + }, + }, + }, + + getEnding: s => { + if (s.unlocked_last) return 'known'; + if (s.heard_critical === 0) return 'stranger'; + if (s.skipped.length >= 7) return 'passing'; + if (s.heard.length >= 9) return 'outline'; + return 'brief'; + }, + + endings: { + known: { + narrate: '《我认识你》。你听完了他大部分的录音,你听到了那条隐藏的最后一条。你不认识他,但你陪他把该说的都说完了。这已经足够了。', + }, + stranger: { + narrate: '《陌生人》。你跳过了所有关于他和你之间联系的录音。他对你来说,还是一个完全的陌生人。也许这样也好。', + }, + passing: { + narrate: '《路过》。你路过了他的大部分录音,只停留了几条。你知道他存在过,但你不知道他是谁。', + }, + outline: { + narrate: '《轮廓》。你听了他生活的碎片,知道了一个大致的形状。你不知道他所有的事,但你知道了一个人活着的样子。', + }, + brief: { + narrate: '《简短的相遇》。你听了几条,然后停下了。也许你听到了你需要听的,也许你还没准备好听更多。这也是一种结果。', + }, + }, +}; diff --git a/game/js/gestures.js b/game/js/gestures.js deleted file mode 100644 index 8185a57..0000000 --- a/game/js/gestures.js +++ /dev/null @@ -1,262 +0,0 @@ -// gestures.js — 触控手势检测器 -// -// 支持 9 种手势: -// 单击 · 双击 · 长按(1s) · 左滑 · 右滑 · 上滑 · 下滑 -// 两指长按(1s) · 三指点击 - -class GestureDetector { - constructor(el) { - this._el = el; - this._handlers = {}; - - this._startX = 0; - this._startY = 0; - this._startTime = 0; - this._tapTimer = null; - this._tapCount = 0; - - // 长按检测 - this._longPressTimer = null; - this._longPressTriggered = false; - - // 多指检测 - this._maxTouches = 0; // 本次触摸中同时出现的最大手指数 - this._multiTouchLPTimer = null; // 两指长按计时器 - this._multiTouchHandled = false; - - this._bindTouch(); - this._bindMouse(); - this._bindKeyboard(); - } - - on(evt, fn) { this._handlers[evt] = fn; return this; } - - _emit(evt) { - dbg(evt); - const fn = this._handlers[evt]; - if (fn) fn(); - } - - // ── 触摸事件 ── - _bindTouch() { - const el = this._el; - - el.addEventListener('touchstart', e => { - e.preventDefault(); - const count = e.touches.length; - - // 记录本次触摸出现过的最大手指数 - if (count > this._maxTouches) this._maxTouches = count; - - if (count === 1) { - // 单指:记录起点 + 启动长按计时 - const t = e.touches[0]; - this._startX = t.clientX; - this._startY = t.clientY; - this._startTime = Date.now(); - this._longPressTriggered = false; - - this._longPressTimer = setTimeout(() => { - this._longPressTriggered = true; - this._longPressTimer = null; - this._emit('longpress'); - }, 1000); - - } else if (count === 2) { - // 两指:取消单指长按,启动两指长按计时 - this._cancelLongPress(); - this._multiTouchLPTimer = setTimeout(() => { - this._multiTouchHandled = true; - this._multiTouchLPTimer = null; - this._emit('twofinger_longpress'); - }, 1000); - - } else if (count >= 3) { - // 三指+:取消所有计时 - this._cancelLongPress(); - this._cancelMultiTouchLP(); - } - }, { passive: false }); - - el.addEventListener('touchmove', e => { - e.preventDefault(); - // 移动超 15px 取消长按 - if (this._longPressTimer && e.touches.length === 1) { - const t = e.touches[0]; - const dx = t.clientX - this._startX; - const dy = t.clientY - this._startY; - if (Math.sqrt(dx * dx + dy * dy) > 15) { - this._cancelLongPress(); - } - } - // 两指移动也取消两指长按 - if (this._multiTouchLPTimer) { - this._cancelMultiTouchLP(); - } - }, { passive: false }); - - el.addEventListener('touchend', e => { - e.preventDefault(); - - // 还有手指在屏幕上,等全部抬起再处理 - if (e.touches.length > 0) return; - - // 全部手指抬起 — 根据 maxTouches 判定手势类型 - const max = this._maxTouches; - - // 清理计时器 - this._cancelLongPress(); - this._cancelMultiTouchLP(); - - // 已在计时器中处理过(长按 / 两指长按) - if (this._longPressTriggered || this._multiTouchHandled) { - this._resetMultiTouch(); - return; - } - - // 三指点击 - if (max >= 3) { - this._resetMultiTouch(); - this._emit('threefinger_tap'); - return; - } - - // 两指快速触摸(< 1s)→ 忽略 - if (max === 2) { - this._resetMultiTouch(); - return; - } - - // 单指:正常处理(滑动 / 点击) - this._resetMultiTouch(); - const t = e.changedTouches[0]; - this._process(t.clientX, t.clientY); - }, { passive: false }); - - el.addEventListener('touchcancel', () => { - this._cancelLongPress(); - this._cancelMultiTouchLP(); - this._resetMultiTouch(); - this._reset(); - }, { passive: false }); - } - - // ── 鼠标事件(桌面调试)── - _bindMouse() { - this._el.addEventListener('mousedown', e => { - this._startX = e.clientX; - this._startY = e.clientY; - this._startTime = Date.now(); - this._longPressTriggered = false; - - this._longPressTimer = setTimeout(() => { - this._longPressTriggered = true; - this._longPressTimer = null; - this._emit('longpress'); - }, 1000); - }); - this._el.addEventListener('mouseup', e => { - this._cancelLongPress(); - if (this._longPressTriggered) { - this._longPressTriggered = false; - return; - } - this._process(e.clientX, e.clientY); - }); - this._el.addEventListener('mousemove', e => { - if (this._longPressTimer) { - const dx = e.clientX - this._startX; - const dy = e.clientY - this._startY; - if (Math.sqrt(dx * dx + dy * dy) > 15) { - this._cancelLongPress(); - } - } - }); - } - - // ── 键盘事件(桌面调试)── - _bindKeyboard() { - document.addEventListener('keydown', e => { - switch (e.code) { - case 'Space': e.preventDefault(); this._emit('singletap'); break; - case 'Enter': e.preventDefault(); this._emit('doubletap'); break; - case 'ArrowLeft': e.preventDefault(); this._emit('swipe_left'); break; - case 'ArrowRight': e.preventDefault(); this._emit('swipe_right'); break; - case 'ArrowUp': e.preventDefault(); this._emit('swipe_up'); break; - case 'ArrowDown': e.preventDefault(); this._emit('swipe_down'); break; - case 'KeyL': e.preventDefault(); this._emit('longpress'); break; - case 'KeyP': e.preventDefault(); this._emit('twofinger_longpress'); break; - case 'KeyQ': e.preventDefault(); this._emit('threefinger_tap'); break; - } - }); - } - - // ── 手势判定(单指) ── - _process(endX, endY) { - const dx = endX - this._startX; - const dy = endY - this._startY; - const dist = Math.sqrt(dx * dx + dy * dy); - const dt = Date.now() - this._startTime; - - // 滑动:距离 > 35px - if (dist > 35) { - const angle = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI); - let dir = null; - - if (angle < 45) dir = 'right'; - else if (angle > 135) dir = 'left'; - else if (dy < 0 && Math.abs(dy) > Math.abs(dx)) dir = 'up'; - else if (dy > 0 && Math.abs(dy) > Math.abs(dx)) dir = 'down'; - - if (dir) { - this._tapCount = 0; - clearTimeout(this._tapTimer); - this._emit(`swipe_${dir}`); - return; - } - } - - // 点击:距离 < 20px,时间 < 500ms - if (dist < 20 && dt < 500) { - this._tapCount++; - if (this._tapCount === 1) { - this._tapTimer = setTimeout(() => { - this._tapCount = 0; - this._emit('singletap'); - }, 250); - } else if (this._tapCount >= 2) { - clearTimeout(this._tapTimer); - this._tapCount = 0; - this._emit('doubletap'); - } - } - } - - // ── 工具方法 ── - - _cancelLongPress() { - if (this._longPressTimer) { - clearTimeout(this._longPressTimer); - this._longPressTimer = null; - } - } - - _cancelMultiTouchLP() { - if (this._multiTouchLPTimer) { - clearTimeout(this._multiTouchLPTimer); - this._multiTouchLPTimer = null; - } - } - - _resetMultiTouch() { - this._maxTouches = 0; - this._multiTouchHandled = false; - } - - _reset() { - clearTimeout(this._tapTimer); - this._tapCount = 0; - this._longPressTriggered = false; - this._resetMultiTouch(); - } -} diff --git a/game/js/input.js b/game/js/input.js new file mode 100644 index 0000000..5de8a82 --- /dev/null +++ b/game/js/input.js @@ -0,0 +1,74 @@ +// input.js — 输入检测 +// 只保留两种操作:单击 / 双击 +// 键盘:空格=单击,回车=双击(桌面调试) + +class InputDetector { + constructor(el) { + this.el = el; + this.onTap = null; + this.onDoubleTap = null; + this.enabled = false; + + this._last = 0; + this._timer = null; + this._WIN = 300; // ms双击判定窗口 + + this._bindTouch(); + this._bindMouse(); + this._bindKeyboard(); + } + + enable() { this.enabled = true; } + disable() { this.enabled = false; } + + _fire(e) { + e.preventDefault(); + if (!this.enabled) return; + const now = Date.now(); + if (now - this._last < this._WIN) { + // 双击 + clearTimeout(this._timer); + this._last = 0; + Haptic.confirm(); + SFX_UI.skip(); + dbg('[input] double-tap'); + if (this.onDoubleTap) this.onDoubleTap(); + } else { + this._last = now; + this._timer = setTimeout(() => { + this._last = 0; + Haptic.confirm(); + SFX_UI.tap(); + dbg('[input] tap'); + if (this.onTap) this.onTap(); + }, this._WIN); + } + } + + _bindTouch() { + this.el.addEventListener('touchend', e => this._fire(e), { passive: false }); + } + + _bindMouse() { + this.el.addEventListener('click', e => this._fire(e)); + } + + _bindKeyboard() { + document.addEventListener('keydown', e => { + if (!this.enabled) return; + if (e.code === 'Space') { + e.preventDefault(); + Haptic.confirm(); + SFX_UI.tap(); + dbg('[input] tap (kbd)'); + if (this.onTap) this.onTap(); + } else if (e.code === 'Enter') { + e.preventDefault(); + Haptic.confirm(); + SFX_UI.skip(); + dbg('[input] double-tap (kbd)'); + if (this.onDoubleTap) this.onDoubleTap(); + } + }); + } +} diff --git a/game/js/main.js b/game/js/main.js index 7498d4d..330f8b4 100644 --- a/game/js/main.js +++ b/game/js/main.js @@ -1,58 +1,51 @@ -// main.js — 游戏入口 +// main.js — 入口 -let engine = null; -let gestures = null; +const DEV = location.search.includes('dev'); +const TEST = location.search.includes('test'); -async function main() { - TTS.init(); +let _booted = false; - // 初始化音频(必须在用户手势后) - await Snd.init(); +async function boot(e) { + if (_booted) return; + _booted = true; + e.preventDefault(); - // 请求屏幕常亮 + // 屏幕常亮 try { - if ('wakeLock' in navigator) { - window._wakeLock = await navigator.wakeLock.request('screen'); - } + if ('wakeLock' in navigator) navigator.wakeLock.request('screen').catch(() => {}); } catch (_) {} - // 绑定手势 - const gameEl = document.getElementById('game'); - gestures = new GestureDetector(gameEl); + // 初始化子系统 + await Narrator.init(); + await Snd.init(); - // 创建并启动引擎 - engine = new GameEngine(); - engine.init(gestures); - await engine.start(); + // 创建输入检测器 + const touchEl = document.getElementById('g'); + const input = new InputDetector(touchEl); + + // 创建引擎 + const engine = new SceneEngine(); + + // 先启用输入 + input.enable(); + + // 启动选择器 + const selector = new GameSelector(engine, input); + await selector.start(); if (DEV) { document.getElementById('dbg').style.display = 'block'; - document.title = '林夏 [DEV]'; + document.title = '盲游 [DEV]'; + window.__engine = engine; + window.__input = input; + window.__selector = selector; } } -// 防止双重启动 -let _started = false; -function startGame(el) { - if (_started) return; - _started = true; - el.style.transition = 'opacity 0.8s'; - el.style.opacity = '0'; - setTimeout(() => { el.style.display = 'none'; }, 800); - main(); -} +const _el = document.getElementById('g'); +_el.addEventListener('touchend', boot, { once: true, passive: false }); +_el.addEventListener('click', boot, { once: true }); -// 触摸启动(手机) -document.getElementById('start').addEventListener('touchend', function(e) { - e.preventDefault(); - startGame(this); -}, { once: true }); - -// 鼠标点击(电脑调试) -document.getElementById('start').addEventListener('click', function() { - startGame(this); -}, { once: true }); - -// 防止页面滚动/缩放 -document.addEventListener('touchmove', e => e.preventDefault(), { passive: false }); +// 防止页面缩放 / 滚动 +document.addEventListener('touchmove', e => e.preventDefault(), { passive: false }); document.addEventListener('gesturestart', e => e.preventDefault()); diff --git a/game/js/narrator.js b/game/js/narrator.js new file mode 100644 index 0000000..c4a27f8 --- /dev/null +++ b/game/js/narrator.js @@ -0,0 +1,109 @@ +// narrator.js — 旁白系统 +// 全程语音播报:场景描述、操作提示、状态反馈。 +// 这是无障碍核心:玩家闭眼也能完全掌握游戏状态。 + +const Narrator = (() => { + let _voice = null; + let _busy = false; + let _queue = []; // [{ text, resolve }] + let _repTimer = null; + let _repFn = null; + + // ── 初始化:找最佳中文语音 ── + function init() { + return new Promise(resolve => { + if (!window.speechSynthesis) { resolve(); return; } + const load = () => { + const vs = speechSynthesis.getVoices(); + _voice = vs.find(v => v.lang === 'zh-CN') + || vs.find(v => v.lang.startsWith('zh')) + || vs[0] || null; + resolve(); + }; + if (speechSynthesis.getVoices().length > 0) load(); + else speechSynthesis.addEventListener('voiceschanged', load, { once: true }); + }); + } + + // ── 底层单句播放 ── + function _speak(text) { + return new Promise(resolve => { + if (!window.speechSynthesis) { resolve(); return; } + const u = new SpeechSynthesisUtterance(text); + u.lang = 'zh-CN'; + u.rate = 0.88; + u.pitch = 1.0; + u.volume = 1.0; + if (_voice) u.voice = _voice; + u.onend = resolve; + u.onerror = resolve; + speechSynthesis.speak(u); + }); + } + + // ── 推进队列 ── + async function _next() { + if (_busy || _queue.length === 0) return; + _busy = true; + const item = _queue.shift(); + dbg(`[N] ${item.text.slice(0, 40)}`); + await _speak(item.text); + _busy = false; + if (item.resolve) item.resolve(); + _next(); + } + + // ── 排队说(fire-and-forget)── + function say(text, onDone) { + _queue.push({ text, resolve: onDone || null }); + _next(); + } + + // ── 说完再继续(await 用)── + function sayAndWait(text) { + return new Promise(resolve => { + _queue.push({ text, resolve }); + _next(); + }); + } + + // ── 立刻打断当前语音,说新内容 ── + function interrupt(text, onDone) { + if (window.speechSynthesis) speechSynthesis.cancel(); + _queue = []; + _busy = false; + say(text, onDone); + } + + // ── 宣告操作选项 + 每12秒自动重复 ── + function options(tapLabel, dtLabel) { + clearTimeout(_repTimer); + let tip = ''; + if (tapLabel && dtLabel) tip = `单击${tapLabel},双击${dtLabel}。`; + else if (tapLabel) tip = `单击${tapLabel}。`; + else if (dtLabel) tip = `双击${dtLabel}。`; + + _repFn = () => { + interrupt(`——还在等你。${tip}`); + _repTimer = setTimeout(_repFn, 14000); + }; + say(tip); + _repTimer = setTimeout(_repFn, 14000); + } + + // ── 清除重复定时器(选择后调用)── + function clearOptions() { + clearTimeout(_repTimer); + _repFn = null; + } + + // ── 停止一切 ── + function stop() { + if (window.speechSynthesis) speechSynthesis.cancel(); + _queue = []; + _busy = false; + clearOptions(); + } + + return { init, say, sayAndWait, interrupt, options, clearOptions, stop }; +})(); diff --git a/game/js/profile.js b/game/js/profile.js deleted file mode 100644 index 803b276..0000000 --- a/game/js/profile.js +++ /dev/null @@ -1,22 +0,0 @@ -// profile.js — 行为画像追踪器(avoidance / engagement / nostalgia) - -class ProfileTracker { - constructor() { - this.scores = { avoidance: 0, engagement: 0, nostalgia: 0 }; - } - - add(effects) { - if (!effects) return; - for (const k of ['avoidance', 'engagement', 'nostalgia']) { - if (effects[k]) this.scores[k] += effects[k]; - } - } - - // 获取最终画像标签 - get label() { - const s = this.scores; - if (s.engagement > s.avoidance && s.engagement >= s.nostalgia) return 'engagement'; - if (s.nostalgia > s.avoidance) return 'nostalgia'; - return 'avoidance'; - } -} diff --git a/game/js/selector.js b/game/js/selector.js new file mode 100644 index 0000000..4c53cfc --- /dev/null +++ b/game/js/selector.js @@ -0,0 +1,84 @@ +// selector.js — 游戏选择器 +// 单击:切换到下一个游戏 +// 双击:开始当前选中的游戏 + +const GAME_CATALOG = [ + { + title: '林夏', + desc: '一部碎屏手机,一个北漂的夜晚。十六条消息,决定明天的自己。', + get: () => GAME_LINXIA, + }, + { + title: '一念', + desc: '内心法庭。七个声音在等你,今晚必须做一个决定。', + get: () => GAME_YINIAN, + }, + { + title: '守夜', + desc: '你是夜班保安,独自坐在监控室。楼里传来声音,去查,还是不动。', + get: () => GAME_SHOUYE, + }, + { + title: '候诊', + desc: '等待检查结果的两个小时。等待本身就是一种体验。', + get: () => GAME_HOUZHEN, + }, + { + title: '遗声', + desc: '一个陌生人的手机,二十二条录音。听,还是跳过——跳过就永远不知道了。', + get: () => GAME_YISHENG, + }, + { + title: '浮', + desc: '感官漂流。声音漂过来,抓住,还是让它走。结束时你会听到你带走了什么。', + get: () => GAME_FU, + }, +]; + +class GameSelector { + constructor(engine, input) { + this._engine = engine; + this._input = input; + this._idx = 0; + this._active = false; + } + + async start() { + this._active = true; + this._idx = 0; + + this._input.onTap = () => this._next(); + this._input.onDoubleTap = () => this._pick(); + + await Narrator.sayAndWait( + '欢迎来到盲游。共六个游戏。单击听下一个介绍,双击开始当前游戏。' + ); + this._announce(); + } + + _announce() { + const g = GAME_CATALOG[this._idx]; + const n = this._idx + 1; + Narrator.interrupt( + `第${n}个:《${g.title}》。${g.desc}单击换一个,双击开始。` + ); + } + + _next() { + if (!this._active) return; + this._idx = (this._idx + 1) % GAME_CATALOG.length; + this._announce(); + } + + async _pick() { + if (!this._active) return; + this._active = false; + const g = GAME_CATALOG[this._idx]; + Narrator.interrupt(`开始《${g.title}》。`); + await new Promise(r => setTimeout(r, 1500)); + this._engine.run(g.get(), this._input, () => { + // 游戏结束后回到选择器 + setTimeout(() => this.start(), 2000); + }); + } +} diff --git a/game/js/story.js b/game/js/story.js deleted file mode 100644 index 75d8b2f..0000000 --- a/game/js/story.js +++ /dev/null @@ -1,674 +0,0 @@ -// story.js — 消息数据 v2(VoxCPM2 语音 + 多轮对话) -// -// 选择方向的情感含义(全局一致): -// 单击(tap) → 简单回应 -// 双击 → 不回复 / 跳过 -// 右滑(right) → 温暖 / 接受 / 开放 -// 左滑(left) → 冷淡 / 拒绝 / 疏远 -// 上滑(up) → 好奇 / 主动 / 追问 -// 下滑(down) → 沉默 / 退缩 -// -// 每个 choice 结构: -// linxia: 林夏回应的音频路径(null/缺省 = 沉默) -// npcReact: NPC反应音频数组(缺省 = 无反应) -// isSilence: 沉默选项标记 -// state: 剧情状态变更 -// profile: 画像分数变更 -// followUp: 可选的下一轮对话 { choices: { ... } } - -const A = '../audio/mp3'; -const T = `${A}/tts`; // VoxCPM2 生成的所有语音 -const MUS = `${A}/music`; -const SFX = `${A}/sfx`; -const AMB = `${A}/ambience`; - -// ── 快捷函数 ── -const npc = f => `${T}/${f}`; // NPC Round 1 台词 -const lx = f => `${T}/${f}`; // 林夏回应 -const rx = f => `${T}/${f}`; // NPC 反应 - -const MESSAGES = [ - - // ══════════════════════════════════════════ - // 01 · 妈妈 · 电话 - // ══════════════════════════════════════════ - { - id: 1, type: 'call', - sender: 'mom', senderName: '妈妈', - ringtone: `${MUS}/lv11_ring_mom_v1.mp3`, - audio: [npc('lv11_2103_mom_call_01_wav_v1.mp3')], - autoReply: lx('v2_msg01_linxia_auto_wav_v1.mp3'), - stateOnAnswer: { MOM_LINK: true }, - profileOnMiss: { avoidance: 2 }, - profileOnAnswer: { engagement: 1 }, - }, - - // ══════════════════════════════════════════ - // 02 · 小美 · 语音 ×3(教学关) - // ══════════════════════════════════════════ - { - id: 2, type: 'wechat_voice', - sender: 'xiaomei', senderName: '小美', - audio: [ - npc('lv11_2115_xiaomei_voice_01_wav_v1.mp3'), - npc('lv11_2115_xiaomei_voice_02_wav_v1.mp3'), - npc('lv11_2115_xiaomei_voice_03_wav_v1.mp3'), - ], - choices: { - right: { - linxia: lx('v2_msg02_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg02_react_warm_wav_v1.mp3')], - profile: { engagement: 2 }, - }, - left: { - linxia: lx('v2_msg02_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg02_react_cold_wav_v1.mp3')], - profile: { avoidance: 1 }, - }, - up: { - linxia: lx('v2_msg02_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg02_react_curious_wav_v1.mp3')], - profile: { engagement: 1 }, - followUp: { - choices: { - right: { - linxia: lx('v2_msg02_r3_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg02_r3_react_wav_v1.mp3')], - profile: { nostalgia: 1 }, - }, - }, - }, - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg02_react_silent_wav_v1.mp3')], - profile: { avoidance: 2 }, - }, - tap: { - linxia: lx('v2_msg02_linxia_tap_wav_v1.mp3'), - npcReact: [rx('v2_msg02_react_tap_wav_v1.mp3')], - }, - }, - }, - - // ══════════════════════════════════════════ - // 03 · 外卖小哥 · 电话 - // ══════════════════════════════════════════ - { - id: 3, type: 'call', - sender: 'delivery', senderName: '外卖小哥', - ringtone: `${MUS}/lv11_ring_delivery_v1.mp3`, - audio: [npc('lv11_2120_delivery_call_01_wav_v1.mp3')], - autoReply: lx('v2_msg03_linxia_auto_wav_v1.mp3'), - profileOnMiss: { avoidance: 1 }, - profileOnAnswer: { engagement: 1 }, - }, - - // ══════════════════════════════════════════ - // 04 · 阿哲 · 语音(前男友要东西) - // ══════════════════════════════════════════ - { - id: 4, type: 'wechat_voice', - sender: 'azhe', senderName: '阿哲', - audio: [npc('lv11_2130_azhe_voice_01_wav_v1.mp3')], - choices: { - right: { - linxia: lx('v2_msg04_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg04_react_warm_wav_v1.mp3')], - profile: { engagement: 2 }, - followUp: { - // 阿哲问"你呢?"后 - choices: { - right: { - linxia: lx('v2_msg04_r3w_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg04_r3w_react_warm_wav_v1.mp3')], - }, - left: { - linxia: lx('v2_msg04_r3w_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg04_r3w_react_cold_wav_v1.mp3')], - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg04_r3w_react_silent_wav_v1.mp3')], - }, - }, - }, - }, - left: { - linxia: lx('v2_msg04_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg04_react_cold_wav_v1.mp3')], - state: { HE_BACK: false }, - profile: { avoidance: 1 }, - }, - up: { - linxia: lx('v2_msg04_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg04_react_curious_wav_v1.mp3')], - profile: { engagement: 2 }, - followUp: { - // 阿哲说"不太方便"后 - choices: { - up: { - linxia: lx('v2_msg04_r3c_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg04_r3c_react_curious_wav_v1.mp3')], - state: { HE_BACK: false }, - }, - right: { - linxia: lx('v2_msg04_r3c_linxia_warm_wav_v1.mp3'), - }, - }, - }, - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg04_react_silent_wav_v1.mp3')], - state: { HE_BACK: null }, - profile: { avoidance: 2 }, - }, - tap: { - linxia: lx('v2_msg04_linxia_tap_wav_v1.mp3'), - npcReact: [rx('v2_msg04_react_tap_wav_v1.mp3')], - state: { HE_BACK: false }, - profile: { engagement: 1 }, - }, - }, - }, - - // ══════════════════════════════════════════ - // 05 · HR王姐 · 语音 - // ══════════════════════════════════════════ - { - id: 5, type: 'wechat_voice', - sender: 'hr', senderName: 'HR王姐', - audio: [npc('lv11_2145_hr_voice_01_wav_v1.mp3')], - choices: { - right: { - linxia: lx('v2_msg05_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg05_react_warm_wav_v1.mp3')], - profile: { engagement: 1 }, - }, - left: { - linxia: lx('v2_msg05_linxia_cold_wav_v1.mp3'), - }, - up: { - linxia: lx('v2_msg05_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg05_react_curious_wav_v1.mp3')], - profile: { engagement: 2 }, - }, - down: { - isSilence: true, - profile: { avoidance: 1 }, - }, - tap: { - linxia: lx('v2_msg05_linxia_tap_wav_v1.mp3'), - }, - }, - }, - - // ══════════════════════════════════════════ - // 06 · 安安 · 语音(酒吧邀约) - // ══════════════════════════════════════════ - { - id: 6, type: 'wechat_voice', - sender: 'anan', senderName: '安安', - audio: [npc('lv11_2200_anan_voice_01_wav_v1.mp3')], - choices: { - right: { - linxia: lx('v2_msg06_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg06_react_warm_wav_v1.mp3')], - profile: { engagement: 2 }, - }, - left: { - linxia: lx('v2_msg06_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg06_react_cold_wav_v1.mp3')], - }, - up: { - linxia: lx('v2_msg06_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg06_react_curious_wav_v1.mp3')], - profile: { engagement: 1 }, - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg06_react_silent_wav_v1.mp3')], - profile: { avoidance: 1 }, - }, - tap: { - linxia: lx('v2_msg06_linxia_tap_wav_v1.mp3'), - npcReact: [rx('v2_msg06_react_tap_wav_v1.mp3')], - }, - }, - }, - - // ══════════════════════════════════════════ - // 07 · 寝室群 · 5条语音 - // ══════════════════════════════════════════ - { - id: 7, type: 'wechat_voice', - sender: 'dorm', senderName: '寝室群', - audio: [ - npc('lv11_2215_dorm_a_voice_01_wav_v1.mp3'), - npc('lv11_2215_dorm_b_voice_01_wav_v1.mp3'), - npc('lv11_2215_dorm_c_voice_01_wav_v1.mp3'), - npc('lv11_2215_dorm_a_voice_02_wav_v1.mp3'), - npc('lv11_2215_dorm_b_voice_02_wav_v1.mp3'), - ], - choices: { - right: { - linxia: lx('v2_msg07_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg07_react_warm_wav_v1.mp3')], - state: { GROUP_REPLY: true }, - profile: { engagement: 2 }, - followUp: { - // 室友A问"你最近怎么样"后 - choices: { - right: { - linxia: lx('v2_msg07_r3_linxia_warm_wav_v1.mp3'), - profile: { engagement: 1 }, - }, - left: { - linxia: lx('v2_msg07_r3_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg07_r3_react_cold_wav_v1.mp3')], - }, - }, - }, - }, - left: { - linxia: lx('v2_msg07_linxia_cold_wav_v1.mp3'), - profile: { avoidance: 1 }, - }, - up: { - linxia: lx('v2_msg07_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg07_react_curious_wav_v1.mp3')], - state: { GROUP_REPLY: true }, - profile: { engagement: 2 }, - }, - down: { - isSilence: true, - profile: { avoidance: 2 }, - }, - tap: { - linxia: lx('v2_linxia_generic_hmm_wav_v1.mp3'), - npcReact: [rx('v2_msg07_react_tap_wav_v1.mp3')], - state: { GROUP_REPLY: true }, - }, - }, - }, - - // ══════════════════════════════════════════ - // 08 · 未知号码 · 电话(悬疑钩子) - // ══════════════════════════════════════════ - { - id: 8, type: 'call', - sender: 'unknown', senderName: '未知号码', - ringtone: `${MUS}/lv11_ring_unknown_v1.mp3`, - audio: [`${SFX}/sfx_breathing_unknown_v1.mp3`], - autoHangup: true, - sfxAfter: `${SFX}/sfx_heartbeat_fast_v1.mp3`, - profileOnMiss: { avoidance: 1 }, - profileOnAnswer: { nostalgia: 1 }, - }, - - // ══════════════════════════════════════════ - // 09 · 小美(醉)· 4段语音 - // ══════════════════════════════════════════ - { - id: 9, type: 'wechat_voice', - sender: 'xiaomei', senderName: '小美', - audio: [ - npc('lv11_2245_xiaomei_voice_01_seg1_wav_v1.mp3'), - npc('lv11_2245_xiaomei_voice_01_seg2_wav_v1.mp3'), - npc('lv11_2245_xiaomei_voice_01_seg3_wav_v1.mp3'), - npc('lv11_2245_xiaomei_voice_01_seg4_wav_v1.mp3'), - ], - bgm: `${MUS}/lv11_bgm_m2_suspense_v1.mp3`, - choices: { - right: { - linxia: lx('v2_msg09_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg09_react_warm_wav_v1.mp3')], - profile: { engagement: 2 }, - }, - left: { - linxia: lx('v2_msg09_linxia_cold_wav_v1.mp3'), - profile: { avoidance: 1 }, - }, - up: { - linxia: lx('v2_msg09_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg09_react_curious_wav_v1.mp3')], - profile: { engagement: 2 }, - followUp: { - // 小美说"怕你恨我"后 - choices: { - right: { - linxia: lx('v2_msg09_r3_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg09_r3_react_warm_wav_v1.mp3')], - }, - left: { - linxia: lx('v2_msg09_r3_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg09_r3_react_cold_wav_v1.mp3')], - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg09_r3_react_silent_q_wav_v1.mp3')], - }, - }, - }, - }, - down: { - isSilence: true, - profile: { avoidance: 2, nostalgia: 1 }, - }, - tap: { - linxia: lx('v2_msg09_linxia_tap_wav_v1.mp3'), - npcReact: [rx('v2_msg09_react_tap_wav_v1.mp3')], - profile: { nostalgia: 1 }, - }, - }, - }, - - // ══════════════════════════════════════════ - // 10 · 妈妈 · 文字消息(多层嵌套对话) - // ══════════════════════════════════════════ - { - id: 10, type: 'wechat_text', - sender: 'mom', senderName: '妈妈', - text: '睡了吗?', - choices: { - right: { - linxia: lx('v2_msg10_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg10_react_warm_wav_v1.mp3')], - state: { MOM_LINK: true }, - profile: { engagement: 2 }, - followUp: { - // 妈妈问"今天怎么样"后 - choices: { - right: { - linxia: lx('v2_msg10_rw_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg10_rw_react_warm_wav_v1.mp3')], - }, - up: { - linxia: lx('v2_msg10_rw_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg10_rw_react_curious_wav_v1.mp3')], - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg10_rw_react_silent_wav_v1.mp3')], - profile: { avoidance: 1 }, - }, - }, - }, - }, - left: { - linxia: lx('v2_msg10_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg10_react_cold_wav_v1.mp3')], - state: { MOM_LINK: true }, - profile: { avoidance: 1 }, - }, - up: { - linxia: lx('v2_msg10_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg10_call_mom_wav_v1.mp3')], - state: { MOM_LINK: true }, - profile: { engagement: 3 }, - followUp: { - // 妈妈打来电话说"怎么了夏夏?"后 - choices: { - right: { - linxia: lx('v2_msg10_call_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg10_call_react_warm_wav_v1.mp3')], - }, - up: { - linxia: lx('v2_msg10_call_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg10_call_react_curious_wav_v1.mp3')], - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg10_call_react_down_wav_v1.mp3')], - profile: { avoidance: 1 }, - }, - }, - }, - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg10_react_silent_wav_v1.mp3')], - profile: { avoidance: 2 }, - }, - tap: { - linxia: lx('v2_msg10_linxia_tap_wav_v1.mp3'), - npcReact: [rx('v2_msg10_react_tap_wav_v1.mp3')], - state: { MOM_LINK: true }, - }, - }, - }, - - // ══════════════════════════════════════════ - // 11 · 阿哲 · 电话(关键分支:来不来) - // ══════════════════════════════════════════ - { - id: 11, type: 'call', - sender: 'azhe', senderName: '阿哲', - ringtone: `${MUS}/lv11_ring_azhe_v1.mp3`, - audio: [npc('lv11_2315_azhe_call_01_wav_v1.mp3')], - profileOnMiss: { avoidance: 2 }, - profileOnAnswer: { engagement: 1 }, - choices: { - right: { - linxia: lx('v2_msg11_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg11_react_warm_wav_v1.mp3')], - state: { HE_BACK: true }, - }, - left: { - linxia: lx('v2_msg11_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg11_react_cold_wav_v1.mp3')], - state: { HE_BACK: false }, - }, - up: { - linxia: lx('v2_msg11_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg11_react_curious_wav_v1.mp3')], - followUp: { - choices: { - right: { - linxia: lx('v2_msg11_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg11_react_warm_wav_v1.mp3')], - state: { HE_BACK: true }, - }, - left: { - linxia: lx('v2_msg11_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg11_react_cold_wav_v1.mp3')], - state: { HE_BACK: false }, - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg11_r3_react_silent_wav_v1.mp3')], - state: { HE_BACK: false }, - profile: { avoidance: 1 }, - }, - }, - }, - }, - down: { - isSilence: true, - state: { HE_BACK: false }, - }, - tap: { - linxia: lx('v2_msg11_linxia_tap_wav_v1.mp3'), - npcReact: [rx('v2_msg11_react_tap_wav_v1.mp3')], - state: { HE_BACK: false }, - }, - }, - }, - - // ══════════════════════════════════════════ - // 12 · 周南 · 语音(老同学) - // ══════════════════════════════════════════ - { - id: 12, type: 'wechat_voice', - sender: 'zhounan', senderName: '周南', - audio: [npc('lv11_2330_zhounan_voice_01_wav_v1.mp3')], - choices: { - right: { - linxia: lx('v2_msg12_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg12_react_warm_wav_v1.mp3')], - state: { ZHOUNAN_DEPTH: 1 }, - profile: { engagement: 2, nostalgia: 1 }, - }, - left: { - linxia: lx('v2_msg12_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg12_react_cold_wav_v1.mp3')], - state: { ZHOUNAN_DEPTH: 1 }, - profile: { nostalgia: 1 }, - }, - up: { - linxia: lx('v2_msg12_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg12_react_curious_wav_v1.mp3')], - state: { ZHOUNAN_DEPTH: 1 }, - profile: { nostalgia: 2 }, - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg12_react_silent_wav_v1.mp3')], - profile: { nostalgia: 1 }, - }, - tap: { - linxia: lx('v2_msg12_linxia_tap_wav_v1.mp3'), - npcReact: [rx('v2_msg12_react_tap_wav_v1.mp3')], - state: { ZHOUNAN_DEPTH: 1 }, - }, - }, - }, - - // ══════════════════════════════════════════ - // 13 · 周南 · 4条长语音(他的十年) - // ══════════════════════════════════════════ - { - id: 13, type: 'wechat_voice', - sender: 'zhounan', senderName: '周南', - audio: [ - npc('lv11_2345_zhounan_voice_01_wav_v1.mp3'), - npc('lv11_2345_zhounan_voice_02_wav_v1.mp3'), - npc('lv11_2345_zhounan_voice_03_wav_v1.mp3'), - npc('lv11_2345_zhounan_voice_04_wav_v1.mp3'), - ], - bgm: `${MUS}/lv11_bgm_m3_zhounan_v1.mp3`, - choices: { - right: { - linxia: lx('v2_msg13_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg13_react_warm_wav_v1.mp3')], - state: { ZHOUNAN_DEPTH_ADD: 1 }, - profile: { engagement: 2 }, - followUp: { - // 周南问"你在北京过得好吗"后 - choices: { - right: { - linxia: lx('v2_msg13_r3_linxia_warm_wav_v1.mp3'), - npcReact: [rx('v2_msg13_r3_react_warm_wav_v1.mp3')], - state: { ZHOUNAN_SHARE: true }, - }, - left: { - linxia: lx('v2_msg13_r3_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg13_r3_react_cold_wav_v1.mp3')], - }, - up: { - linxia: lx('v2_msg13_r3_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg13_r3_react_curious_wav_v1.mp3')], - }, - }, - }, - }, - left: { - linxia: lx('v2_msg13_linxia_cold_wav_v1.mp3'), - npcReact: [rx('v2_msg13_react_cold_wav_v1.mp3')], - state: { ZHOUNAN_DEPTH_ADD: 1 }, - profile: { nostalgia: 1 }, - }, - up: { - linxia: lx('v2_msg13_linxia_curious_wav_v1.mp3'), - npcReact: [rx('v2_msg13_react_curious_wav_v1.mp3')], - state: { ZHOUNAN_DEPTH_ADD: 1, ZHOUNAN_SHARE: true }, - profile: { nostalgia: 2, engagement: 1 }, - }, - down: { - isSilence: true, - npcReact: [rx('v2_msg13_react_silent_wav_v1.mp3')], - profile: { avoidance: 1 }, - }, - tap: { - linxia: lx('v2_msg13_linxia_tap_wav_v1.mp3'), - npcReact: [rx('v2_msg13_react_tap_wav_v1.mp3')], - state: { ZHOUNAN_DEPTH_ADD: 1 }, - }, - }, - }, - - // ══════════════════════════════════════════ - // 14 · 妈妈 · 第二次电话 - // ══════════════════════════════════════════ - { - id: 14, type: 'call', - sender: 'mom', senderName: '妈妈', - ringtone: `${MUS}/lv11_ring_mom_v1.mp3`, - audio: [npc('lv11_0015_mom_call_01_wav_v1.mp3')], - autoReply: lx('v2_msg14_linxia_auto_wav_v1.mp3'), - autoReact: { - condition: 'MOM_LINK', - true: rx('v2_msg14_react_linked_wav_v1.mp3'), - false: rx('v2_msg14_react_unlinked_wav_v1.mp3'), - }, - autoReactPreamble: rx('v2_msg14_mom_ask_wav_v1.mp3'), - stateOnAnswer: { MOM_LINK: true }, - profileOnMiss: { avoidance: 2 }, - profileOnAnswer: { engagement: 2 }, - }, - - // ══════════════════════════════════════════ - // 15 · 时光胶囊 · 系统通知 - // ══════════════════════════════════════════ - { - id: 15, type: 'system_notification', - sender: 'self', senderName: '时光胶囊', - systemAudio: npc('lv11_sys_memo_01_wav_v1.mp3'), - audioByProfile: { - avoidance: npc('lv11_0030_self3y_voice_02_wav_v1.mp3'), - engagement: npc('lv11_0030_self3y_voice_01_wav_v1.mp3'), - nostalgia: npc('lv11_0030_self3y_voice_03_wav_v1.mp3'), - }, - stateOnPlay: { SELF_RECORD: true }, - profileOnPlay: { nostalgia: 2 }, - profileOnSkip: { avoidance: 2 }, - }, - - // ══════════════════════════════════════════ - // 16 · 妈妈 · 长语音 ×8(结局前奏) - // ══════════════════════════════════════════ - { - id: 16, type: 'wechat_voice', - sender: 'mom', senderName: '妈妈', - audio: [ - npc('lv11_0200_mom_voice_01_seg1_wav_v1.mp3'), - npc('lv11_0200_mom_voice_01_seg2_wav_v1.mp3'), - npc('lv11_0200_mom_voice_01_seg3_wav_v1.mp3'), - npc('lv11_0200_mom_voice_01_seg4_wav_v1.mp3'), - npc('lv11_0200_mom_voice_01_seg5_wav_v1.mp3'), - npc('lv11_0200_mom_voice_01_seg6_wav_v1.mp3'), - npc('lv11_0200_mom_voice_01_seg7_wav_v1.mp3'), - npc('lv11_0200_mom_voice_01_seg8_wav_v1.mp3'), - ], - bgm: `${MUS}/lv11_bgm_m4_ending_v1.mp3`, - isEnding: true, - requiresMomLink: true, - }, -]; - -// 角色信息 -const SENDERS = { - mom: { name: '妈妈' }, - azhe: { name: '阿哲' }, - xiaomei: { name: '小美' }, - delivery: { name: '外卖小哥' }, - hr: { name: 'HR王姐' }, - anan: { name: '安安' }, - dorm: { name: '寝室群' }, - unknown: { name: '未知号码' }, - zhounan: { name: '周南' }, - self: { name: '时光胶囊' }, -}; - -const AMBIENCE_DEFAULT = `${AMB}/amb_apartment_night_01_v1.mp3`; diff --git a/game/js/utils.js b/game/js/utils.js index 563a3ec..3a611c3 100644 --- a/game/js/utils.js +++ b/game/js/utils.js @@ -1,6 +1,7 @@ // utils.js — 工具函数 const DEV = new URLSearchParams(location.search).has('dev'); +const TEST = new URLSearchParams(location.search).has('test'); const sleep = ms => new Promise(r => setTimeout(r, ms)); diff --git a/game/操作说明.md b/game/操作说明.md index 2345c39..35b9330 100644 --- a/game/操作说明.md +++ b/game/操作说明.md @@ -1,189 +1,229 @@ -# 《林夏》操作说明 +# 盲游 · 项目说明 + +> 全黑屏纯音频交互游戏合集。没有视觉界面,只有声音和触感。 +> 玩家闭眼也能完整体验——旁白全程解释状态,操作只有六种手势。 --- -## 一、游戏简介 +## 一、项目简介 -全黑屏叙事游戏。玩家扮演 24 岁的林夏,用一部碎屏手机度过今晚 21:00–02:00 的 5 个小时。 +本项目是一个**盲人可玩、明眼人闭眼也好玩**的纯音频叙事游戏合集,共收录 **6 个独立游戏**: -- **没有视觉界面**,只有声音和触感 -- **全程约 60 分钟**(5 小时游戏时间压缩) -- **16 条消息**,5 种结局 -- **不需要说话**,不需要麦克风 +| # | 游戏 | 一句话 | 旁白定位 | +|---|------|--------|---------| +| 1 | 《林夏》 | 一部碎屏手机,一个北漂的夜晚,16 条消息决定明天的自己 | 纪录片叙述者 | +| 2 | 《一念》 | 内心法庭,七个声音在等你,今晚必须做一个决定 | 你的内心独白 | +| 3 | 《守夜》 | 你是夜班保安,独自坐在监控室,楼里传来声音,查还是不查 | 老同事的对讲机留言 | +| 4 | 《候诊》 | 等待检查结果的两小时,等待本身就是一种体验 | 时间本身的声音 | +| 5 | 《遗声》 | 一个陌生人的手机,十五条录音,听还是跳过——跳过就永远不知道 | 手机系统语音 | +| 6 | 《浮》 | 感官漂流,声音漂过来,抓住还是让它走 | 梦里的声音 | + +每个游戏有独立的旁白音色、独立的场景脚本和多种结局。游戏之间互不影响,可任意选择体验。 --- -## 二、开始前准备 +## 二、操作方式(六种手势) -1. 手机和电脑连接**同一 Wi-Fi** -2. 打开手机浏览器,输入地址: +全程**不看屏幕**,只靠六种手势操作。每种手势有固定的语义,贯穿所有游戏: - ``` - http://<服务器IP>:8765/game/ - ``` +| 手势 | 通用含义 | 触觉反馈 | 确认音效 | +|------|---------|---------|---------| +| **单击** | 接受 / 打开 / 简单回应 | 轻触 | 清脆短音 | +| **双击** | 拒绝 / 跳过 / 忽略 | 短促双震 | 下行滑音 | +| **右滑** | 温暖 / 接受 / 靠近 | 流畅长震 | 上行音 | +| **左滑** | 冷淡 / 拒绝 / 推开 | 干脆短震 | 下行音 | +| **上滑** | 好奇 / 追问 / 主动 | 快速上弹 | 快速上行 | +| **下滑** | 沉默 / 退缩 / 不回应 | 极轻慢震 | 极轻低音 | -3. **关灯,戴上耳机** -4. 将手机音量调到适中(建议 60–80%) -5. 轻触屏幕任意位置开始游戏 +### 操作规则 -> **iOS 用户注意**:Safari 不支持震动反馈,操作正常但无震感。建议使用 Android Chrome。 +- **每个场景只启用六种手势中的一部分**。旁白会播报当前可用的操作及其含义,玩家无需记忆。 +- 若玩家做出了当前场景不支持的手势,会有"无效操作"音效提示,不会产生误操作。 +- 没有时间限制,消息不会自动推进,完全由玩家控制节奏。 +- 旁白会在空闲一段时间后自动重复播报当前可用的操作选项,避免玩家迷失。 + +### 游戏选择界面 + +进入后旁白播报:"欢迎来到盲游,共六个游戏。单击听下一个介绍,双击开始当前游戏。"单击切换游戏,双击开始。 + +### 桌面调试键位 + +| 键 | 手势 | +|----|------| +| 空格 | 单击 | +| 回车 | 双击 | +| ↑ | 上滑 | +| ↓ | 下滑 | +| ← | 左滑 | +| → | 右滑 | + +> iOS Safari 不支持震动反馈,操作正常但无震感,建议使用 Android Chrome。 --- -## 三、操作总览 +## 三、旁白系统 -### 来电时 +**旁白是无障碍的核心**。每个游戏都有一位独立的旁白声音,全程为玩家描述: -| 操作 | 效果 | -|------|------| -| **单击 / 右滑** | 接听 | -| **左滑 / 双击** | 拒接 | -| **不动** | 铃声持续(不会自动挂断) | +- 场景发生了什么("晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。") +- 当前可用的操作选项("单击接听,双击拒接。") +- 操作后的结果("你接了。妈妈的声音从电话里传来……") +- 结局陈述 -### 收到消息时 +### 旁白的声音各不相同 -| 操作 | 效果 | -|------|------| -| **单击** | 播放消息内容 | -| **双击 / 左滑** | 跳过这条消息 | +六个游戏的旁白音色、年龄感、情感基调均不同,详见 `docs/旁白音色提示词.md`。例如《林夏》是克制的纪录片女声,《守夜》是疲惫的中年男声,《浮》是飘渺的气声。 -### 播放中 +### 旁白的实现方式 -| 操作 | 效果 | -|------|------| -| **双击** | 跳过当前播放 | -| **长按** | 重播当前消息 | - -### 选择回复(听完消息后) - -系统会播放提示音,进入回复选择状态: - -| 操作 | 情感方向 | -|------|----------| -| **单击** | 简单回应 | -| **右滑** | 温暖 / 接受 | -| **左滑** | 冷淡 / 拒绝 | -| **上滑** | 好奇 / 追问 | -| **下滑** | 沉默 / 退缩 | -| **双击** | 不回复(跳过) | - -> 部分消息的回复会触发**多轮对话**——NPC 回应后,再次进入选择状态。 - -### 随时可用 - -| 操作 | 效果 | -|------|------| -| **两指长按** | 暂停游戏 | -| **单击**(暂停中) | 继续游戏 | -| **三指点击**(暂停中) | 退出游戏 | -| **长按**(空闲/通知时) | 语音播报未读消息数 | +旁白台词**优先使用预录的 TTS 配音**(由模型生成 MP3,见第四节);若预录文件缺失,则回退到浏览器内置 TTS 朗读同一段文本作为兜底。这样即使部分旁白尚未生成配音,游戏也可完整运行。 --- -## 四、消息类型 +## 四、音频制作(全部由模型生成) -游戏中有四种消息形态: +> **核心原则**:所有音乐、音效、配音均由 AI 模型本地生成,不使用任何真人录音或网络素材。 -### 来电(call) -手机长震 + 角色专属铃声。单击/右滑接听,左滑/双击拒接。 +### 4.1 音频分类与生成方式 -### 微信语音(wechat_voice) -两短震 + 系统提示音。单击播放语音内容。 +| 类别 | 内容 | 生成模型 | 数量 | +|------|------|---------|------| +| **角色配音** | 各游戏角色的台词语音 | TTS(VoxCPM2) | 林夏游戏 169 条 | +| **旁白配音** | 六个游戏的旁白台词 | TTS(VoxCPM2) | 约 178 条 | +| **BGM** | 背景音乐 | ACE-Step 1.5 | 4 段 | +| **铃声** | 来电铃声 | ACE-Step 1.5 | 4 条 | +| **环境音** | 场景底噪 | ACE-Step 1.5 | 3 段 | +| **叙事音效** | 呼吸、心跳、敲门等 | ACE-Step 1.5 | 4 条 | +| **UI 音效** | 操作反馈音 | Web Audio 浏览器合成 | 13 种(无文件) | -### 微信文字(wechat_text) -两短震 + 系统提示音。单击后由系统朗读文字内容。 +### 4.2 TTS 配音流程(关键) -### 系统通知(system_notification) -特殊消息,播放系统提示后自动播放内容。 - ---- - -## 五、行为画像 - -游戏全程追踪三个维度(玩家不可见): - -| 维度 | 含义 | 触发行为 | -|------|------|----------| -| **avoidance**(回避) | 林夏今晚想一个人待着 | 跳过消息、不回复、沉默 | -| **engagement**(投入) | 林夏在认真面对每一个人 | 温暖回应、好奇追问 | -| **nostalgia**(怀旧) | 林夏在这一夜反复回望 | 与老同学深聊、分享自己 | - -画像影响**时光胶囊**(消息15)播放哪个版本。 - ---- - -## 六、结局说明(不含剧透) - -共 **5 种结局**,由以下变量决定: - -| 变量 | 触发条件 | -|------|----------| -| 与妈妈的连接 | 是否接了妈妈的电话 / 回了消息 | -| 阿哲是否回来 | 23:15 那通电话的选择 | -| 与周南的深度 | 回应周南的次数与方式 | -| 是否听自己 | 00:30 时光胶囊是否播放 | -| 行为画像 | 整体回应风格 | - -另有一个**隐藏结局 E**,需要与某位角色深度互动才能触发。 - ---- - -## 七、开发者 / 测试模式 - -在 URL 末尾加 `?dev` 可开启开发模式: +**所有 TTS 配音都遵循"先定音色,再生成配音"的两步流程**: ``` -http://<服务器IP>:8765/game/?dev +第一步:用提示词生成音色 + ├─ 根据角色/旁白定位,撰写音色描述提示词(见 docs/旁白音色提示词.md) + ├─ 用 TTS 模型的 Voice Design 能力,按提示词生成多个音色样本 + └─ 人工试听筛选,选定一个参考音频作为该角色的"音色锚点" + +第二步:用 TTS 生成配音 + ├─ 将选定音色注册为参考(voice cloning) + ├─ 输入台词文本 + 表演修饰(语速、情感方向) + ├─ 模型克隆该音色,逐句合成配音 + └─ 输出 MP3,按命名规范部署到 audio/mp3/ ``` -- 消息之间的等待间隔从 2 秒缩短为 0.2 秒 -- 屏幕底部显示调试信息(当前状态) -- 适合快速测试分支和结局 +- **角色音色提示词**:见 `林夏.md`(归档于 `other_docs/`)§7.3 角色音色配置表。 +- **旁白音色提示词**:见 `docs/旁白音色提示词.md`,为六个游戏的旁白分别定义了声音特征、情感基调、中英文 TTS 提示词及朗读标注。 +- **旁白台词脚本**:见 `docs/音频脚本.md`,定义了全部旁白台词、文件命名规范与生成优先级。 + +### 4.3 音频文件命名规范 + +``` +audio/mp3/narrator/{game_id}/{game_id}_{scene_id}_{type}.mp3 +``` + +- `{game_id}`:linxia / yinian / shouye / houzhen / yisheng / fu +- `{scene_id}`:如 msg01、s02、v03、h04、r10、f05 +- `{type}`:intro(开场)/ narrate(描述)/ tap(单击旁白)/ dt(双击旁白)/ ending_{id}(结局)等 + +### 4.4 技术规格 + +| 项目 | 规格 | +|------|------| +| 格式 | MP3,128kbps | +| 采样率 | 44100 Hz | +| 声道 | 单声道 | +| 响度 | -18 LUFS | +| 首尾静音 | 开头 100ms,结尾 200ms | --- -## 八、启动服务器 +## 五、项目结构 -关机重启后需要重新启动 HTTP 服务器: +``` +/home/xsl/blind/ +├── game/ # 游戏前端 +│ ├── index.html # 入口页面(黑屏) +│ ├── manifest.json # PWA 配置 +│ ├── 操作说明.md # 本文件 +│ └── js/ +│ ├── utils.js # 工具函数 + 浏览器 TTS +│ ├── haptics.js # 震动反馈 +│ ├── audio.js # 音频管理器 + 合成 UI 音效 +│ ├── narrator.js # 旁白系统(TTS 播报 + 队列) +│ ├── input.js # 手势输入检测 +│ ├── engine.js # 场景引擎(状态机) +│ ├── selector.js # 游戏选择器 +│ ├── main.js # 入口 +│ └── games/ # 六个游戏数据 +│ ├── linxia.js # 《林夏》 +│ ├── yinian.js # 《一念》 +│ ├── shouye.js # 《守夜》 +│ ├── houzhen.js # 《候诊》 +│ ├── yisheng.js # 《遗声》 +│ └── fu.js # 《浮》 +│ +├── audio/ # 音频资源 + 生成脚本 +│ ├── mp3/ # 游戏运行时音频(184 文件) +│ │ ├── tts/ # 角色配音(169 条) +│ │ ├── music/ # BGM + 铃声(8 条) +│ │ ├── sfx/ # 叙事音效(4 条) +│ │ └── ambience/ # 环境音(3 条) +│ ├── narrator_samples/ # 旁白音色样本(WAV,注册音色用) +│ ├── 00_raw/ # 原始素材 +│ ├── batch_tts_voxcpm.py # TTS 批量生成脚本 +│ ├── gen_music_acestep.py # BGM 生成脚本 +│ ├── gen_sfx_acestep.py # 音效生成脚本 +│ ├── gen_narrator_samples.py # 旁白音色样本生成 +│ ├── batch_narrator_tts.py # 旁白配音批量生成 +│ └── ... # 其他音频处理脚本 +│ +├── voice_mp3/narrator/ # 旁白预录 MP3(按游戏分目录) +│ +├── docs/ # 设计文档 +│ ├── 旁白音色提示词.md # 六游戏旁白音色定义 + TTS 提示词 +│ └── 音频脚本.md # 六游戏旁白台词脚本 + 命名规范 +│ +└── other_docs/ # 历史参考文档(归档) + ├── 林夏_原始设计.md # 《林夏》原始游戏设计文档 + ├── 执行文档_音频素材制作_归档.md # 旧版音频制作执行文档 + ├── gdd/ # 其他游戏的概念设计草稿 + └── case_doc/ # 参考案例 +``` + +--- + +## 六、启动与运行 + +### 启动 HTTP 服务器 ```bash cd /home/xsl/blind python3 -m http.server 8765 --bind 0.0.0.0 & ``` -查看当前 IP: +### 手机访问 -```bash -hostname -I | awk '{print $1}' -``` +1. 手机和电脑连接**同一 Wi-Fi** +2. 查看电脑 IP:`hostname -I | awk '{print $1}'` +3. 手机浏览器输入:`http://<服务器IP>:8765/game/` +4. **关灯,戴上耳机**,音量调到适中(建议 60–80%) +5. 轻触屏幕任意位置开始 --- -## 九、文件结构 +## 七、开发 / 测试模式 + +在 URL 末尾加参数可开启调试模式: ``` -/home/xsl/blind/ -├── game/ -│ ├── index.html 主页面(黑屏) -│ ├── manifest.json PWA 配置 -│ ├── 操作说明.md 本文件 -│ └── js/ -│ ├── utils.js 工具函数 + 浏览器 TTS -│ ├── haptics.js 震动反馈 -│ ├── audio.js 音频管理器 + 合成 UI 音效 -│ ├── gestures.js 单指手势检测 -│ ├── profile.js 行为画像评分 -│ ├── story.js 16条消息数据 -│ ├── engine.js 游戏状态机 -│ └── main.js 入口 -└── audio/ - └── mp3/ - ├── tts/ 角色语音(169条) - ├── music/ BGM + 铃声(8条) - ├── sfx/ 音效(4条) - └── ambience/ 环境底噪(3条) +http://<服务器IP>:8765/game/?dev ``` +- `?dev`:屏幕底部显示调试信息(当前场景状态),缩短消息间隔 +- `?test`:跳过音频播放等待(快速自动化测试用) + --- *"屏幕坏了,但她的一夜还没结束。"* diff --git a/执行文档_音频素材制作.md b/other_docs/执行文档_音频素材制作_归档.md similarity index 100% rename from 执行文档_音频素材制作.md rename to other_docs/执行文档_音频素材制作_归档.md diff --git a/林夏.md b/other_docs/林夏_原始设计.md similarity index 100% rename from 林夏.md rename to other_docs/林夏_原始设计.md diff --git a/技术报告_音频资源制作.md b/技术报告_音频资源制作.md deleted file mode 100644 index 828c92f..0000000 --- a/技术报告_音频资源制作.md +++ /dev/null @@ -1,304 +0,0 @@ -# 音频资源制作技术落地报告 - -> 调研日期:2026-05-20 -> 最后更新:**2026-05-23**(VoxCPM2 v5 克隆落地、游戏音频清理对齐) -> 硬件环境:NVIDIA RTX 5090 (32GB VRAM) / CUDA Driver 12.9 / WSL2 Linux -> **项目现行状态**:见 `audio_2/项目状态说明.md` - ---- - -## 一、本机已有能力总览 - -| 能力域 | 工具 | 状态 | 环境 | -|--------|------|------|------| -| TTS 语音合成 | VoxCPM2 | **运行中** (port **8002**) | `/home/xsl/tts-server/` Python venv | -| TTS 语音合成 | CosyVoice2-0.5B | 已部署,需手动启动 | conda cosyvoice | -| 音乐/铃声生成 | ACE-Step 1.5 | 已部署,需手动启动 | conda acestep | -| 音效/氛围生成 | ACE-Step 1.5 | 同上 | conda acestep | -| 音效/氛围生成 | **Stable Audio Open 1.0** | **已部署** ✅ | conda stableaudio / torch 2.8.0+cu128 | -| 语音识别 (ASR) | SenseVoice Small | 模型已缓存 | HuggingFace cache | -| 语音识别 (ASR) | Whisper Tiny | 模型已缓存 | HuggingFace cache | -| 轻量 TTS | Kokoro-82M | 模型已缓存 | HuggingFace cache | -| 音频处理 | ffmpeg 7.0.2 | 可用 | 系统 PATH | -| 音频分析 | librosa / soundfile / pydub | 可用 | pip | -| 音视频同步 | MuseTalk / LatentSync | 已部署 | conda 环境 | - -### 当前产出数据 (《林夏》项目,2026-05-23) - -| 类型 | 数量 | 路径 | 生成工具 | -|------|------|------|----------| -| TTS 游戏对白 | **169** 条 | `audio/mp3/tts/*_wav_v1.mp3` | VoxCPM2 v5 克隆 | -| TTS 归档产物 | **177** 条 | `audio_2/tts/v5_cloned/*_wav_v5.mp3` | 同上(含台词表全量 + extra) | -| 参考音色 | **13** 角色 | `audio_2/tts/v4_voice_samples/` + `voice/*_ref.wav` | Voice Design 试听选定 | -| BGM | 4 轨 | `audio/mp3/music/` | ACE-Step 1.5 | -| 角色铃声 | 5 条(游戏引用 4 场景) | `audio/mp3/music/` | ACE-Step 1.5 | -| 叙事音效 | 4 条 | `audio/mp3/sfx/` | ACE-Step 1.5 | -| 环境氛围 | 3 条 | `audio/mp3/ambience/` | ACE-Step 1.5 | -| UI 短音效 | 13 种 | 浏览器内 | Web Audio API | -| **游戏 MP3 合计** | **184** | `audio/mp3/` | 已与 story.js 对齐 | - -> 注:8000 端口被 head3d 占用,VoxCPM2 固定 **8002**。旧 `*_mp3_v1.mp3` 与 legacy 目录(`audio/mp3/linxia/`、`react/`)已清理。 - ---- - -## 二、各能力域详细分析 - -### 2.1 TTS 语音合成 - -#### 本机已有 - -| 工具 | 模型大小 | VRAM | 中文质量 | 声音克隆 | 延迟 | 特点 | -|------|---------|------|---------|---------|------|------| -| **VoxCPM2** | 4.7GB | ~6GB | A | 零样本 (5-30s参考音频) | ~1s/句 | FastAPI,`/home/xsl/tts-server/`,port **8002**;支持 Style Control + Voice Design | -| **CosyVoice2-0.5B** | 5.3GB | ~8GB | A+ | 零样本 | ~2s/句 | WebUI,多语言,语速可控,阿里开源 | -| **Kokoro-82M** | <200MB | <2GB (可CPU) | B (中文社区扩展) | 不支持 | <0.3s | 超轻量,英文为主,适合实时场景 | -| **Edge-TTS** | 0 (云端) | 0 | B+ | 不支持 | 网络延迟 | 微软在线服务,免费,多音色 | - -**当前主链路**:《林夏》已落地 **Voice Design 试听 → 克隆锚定 → Style Control 表演**: - -``` -03_gen_voice_samples.py (v4 试听,每角色 5 变体) - → 人工选定参考 wav - → 04_gen_tts_clone.py (v5 全量克隆) - → audio/mp3/tts/*_wav_v1.mp3 (游戏加载) -``` - -API:`POST /v1/speech/styled`(有 voice_id = 克隆+表演;无 voice_id = 纯 Voice Design)。 -备用:CosyVoice2 → Edge-TTS 兜底。 - -#### 推荐部署到本地 - -| 工具 | Stars | 模型大小 | VRAM | 为什么值得部署 | 部署难度 | -|------|-------|---------|------|--------------|---------| -| **F5-TTS** | 14.5k | ~3GB | 8GB | Flow-matching 架构;零样本克隆质量最高;MIT 开源 | 低 (pip install) | -| **ChatTTS** | 39k | ~2GB | 4GB | 专为对话设计;自然笑声/停顿/语气词;适合互动叙事 | 低 (pip install) | - -**部署优先级建议**: -1. **ChatTTS** — 对话自然度最高,适合即兴/随机对白生成 - -#### 线上服务 - -| 服务 | 中文质量 | 声音克隆 | 延迟 | 价格 | 适用场景 | -|------|---------|---------|------|------|---------| -| **MiniMax Speech 2.8** | S | 支持 (30s参考) | <250ms | 按字符计费 | 实时语音交互、低延迟场景 | -| **豆包 TTS 2.0** (字节) | S | 支持 (Seed-ICL) | ~1s | 火山引擎计费 | 中文情感表达最强,指令控制情绪 | -| **通义 TTS** (阿里) | A+ | 支持 (CosyVoice) | 标准 | 免费试用额度 | 与本机 CosyVoice 生态互通 | -| **ElevenLabs** | A | 专业级 | ~100ms | $5-99/月 | 多语言、API 最成熟、配套 SFX | -| **百度语音合成** | A | 有限 | 标准 | Token 计费 | 最老牌、REST 接入最简单 | - ---- - -### 2.2 音乐制作 - -#### 本机已有 - -| 工具 | 模型大小 | VRAM | 能力 | 当前产出 | -|------|---------|------|------|---------| -| **ACE-Step 1.5** | 9.4GB (60个checkpoint) | ~8GB | 文本→音乐/铃声/氛围;支持歌词+人声;20s 生成 4 分钟音频;seed 可复现 | 4轨 BGM + 9 条铃声 | - -**当前配置**:已针对 RTX 5090 修复 cuBLAS 兼容性 (`cublaslt`),turbo 模式。 - -#### 推荐部署到本地 - -| 工具 | Stars | VRAM | 为什么值得部署 | 适用场景 | -|------|-------|------|--------------|---------| -| **DiffRhythm 2** | 新项目 | ~8GB | 最快:10 秒生成 4:45 歌曲;精确歌词对齐;Latent Diffusion 架构 | 快速迭代 BGM 候选 | -| **YuE (乐)** | Apache 2.0 | 8GB(量化)-80GB(全) | 双 LLaMA 架构;完整歌曲(人声+伴奏同步);最接近 Suno 的开源方案 | 需要完整歌曲(含唱) | -| **MusicGen (Meta)** | CC BY-NC | ~12GB fp16 | 研究级纯器乐生成;立体声输出;Loop 素材可控性强 | 纯器乐 BGM / 素材 | -| **Stable Audio Open 1.5** | 已 clone | ~12GB | 47s 立体声 44.1kHz;SFX 和音乐兼顾;代码已在本机 | 音效与短音乐片段 | - -**部署优先级建议**: -1. **Stable Audio Open 1.5** — 代码已在本机 (`/home/xsl/tools/stable-audio-tools/`),只需下载模型权重 + `pip install`,马上可用 -2. **DiffRhythm 2** — 速度碾压 ACE-Step,适合大量迭代试听 -3. **YuE** — 32GB VRAM 可跑量化版,适合需要有歌词人声的完整歌曲 - -#### 线上服务 - -| 服务 | 人声 | 中文歌词 | 时长 | 价格 | 特点 | -|------|------|---------|------|------|------| -| **Suno v5** | 有 | 支持 | ~4min | 免费 300 首/月 | 品质最高;版权诉讼风险 | -| **Udio** | 有 | 支持 | ~4min | 免费额度 | 与 Suno 竞品;已暂停下载功能 | -| **网易天音** | 有 | 中文原生 | 可变 | 按需 | 国内平台,版权更清晰 | - ---- - -### 2.3 音效生成 - -#### 本机已有 - -| 工具 | 能力 | 当前产出 | -|------|------|---------| -| **ACE-Step 1.5** | 氛围类音效 (>=5s):心跳、呼吸、雨声、敲门、猫叫、水壶、环境氛围等 | 18 条 SFX + 6 条氛围 | -| **ffmpeg** | 后期处理:混音叠加、底噪叠加、格式转换、音量调节 | 全流程 | -| **Web Audio API** (游戏内) | 13 种 UI 短音效:合成叮/咚/滴/滑动音,无需外部文件 | 实时合成 | - -**限制**:ACE-Step 擅长氛围/音乐类音效,不擅长短促精确的 UI 音效(<1s 的 click/beep)。 - -#### 推荐部署到本地 - -| 工具 | VRAM | 为什么值得部署 | 适用场景 | -|------|------|--------------|---------| -| **Stable Audio Open 1.5** | ~12GB | 音效生成品质最高的开源方案;44.1kHz 立体声;47s 时长 | 高品质 Foley、环境音 | -| **AudioLDM 2** | 8-16GB | 全能型(SFX + 音乐 + 语音);48kHz;多种模型尺寸 | 短音效、过渡音 | - -**部署优先级建议**: -1. **Stable Audio Open 1.5** — 同上,已有代码,补模型即可 -2. **AudioLDM 2** — pip 安装,轻量推理,适合补充短促音效 - -#### 线上服务 - -| 服务 | 时长上限 | 价格 | 特点 | -|------|---------|------|------| -| **ElevenLabs SFX v2** | 30s | 免费额度 | 制作级品质,支持循环,版税免费 | -| **Stable Audio 2.5** (API) | 3min | 按量 | 音频修复/inpainting,商业安全 | - ---- - -### 2.4 语音克隆 / 变声 - -#### 本机已有 - -VoxCPM2 和 CosyVoice2 均内置零样本声音克隆。《林夏》**13 角色**参考库(2026-05-23 选定): - -``` -audio_2/tts/v4_voice_samples/ # 每角色 1 条选定 wav/mp3 -voice/ - linxia/linxia_ref.wav linxia_v05 - mom/mom_ref.wav mom_v01 - azhe/azhe_ref.wav azhe_v03(男声) - xiaomei/xiaomei_ref.wav xiaomei_v05 - xiaomei/xiaomei_drunk_ref.wav xiaomei_drunk_v05 - zhounan/zhounan_ref.wav zhounan_v05(男声) - hr/ anan/ dorm_a/ dorm_b/ dorm_c/ - delivery/delivery_ref.wav delivery_v04(男声) - linxia/self_3y_ref.wav self_3y_v03 -``` - -批量脚本:`audio_2/scripts/04_gen_tts_clone.py` · 清单:`audio_2/tts/v5_cloned/clone_manifest.tsv` - -#### 推荐部署到本地 - -| 工具 | Stars | 为什么值得 | 适用场景 | -|------|-------|----------|---------| -| **OpenVoice V2** | 36k | 单条音频秒级克隆;跨语言(克隆英文声 → 输出中文)| 快速原型、临时角色 | -| **RVC v2** | 36k | 实时变声;10 分钟数据可训练;社区模型库庞大 | 歌声转换、直播变声 | -| **Seed-VC** | 新 | 零样本相似度最高;解决音色泄漏问题;支持唱歌 | 高精度声音复刻 | - -**部署优先级**:Seed-VC > OpenVoice V2 - ---- - -### 2.5 语音识别 (ASR) — 辅助能力 - -| 工具 | 状态 | 用途 | -|------|------|------| -| **SenseVoice Small** | 模型已缓存 | 中文 ASR,可用于语音标注、字幕生成 | -| **Whisper Tiny** | 模型已缓存 | 多语言 ASR 兜底 | - ---- - -## 三、推荐部署路线图 - -### ✅ 已完成部署(2026-05-20) - -| 序号 | 工具 | 路径 | 模型 | torch | -|------|------|------|------|-------| -| 1 | **Stable Audio Open 1.0** | `/home/xsl/tools/stable-audio-tools/` | `/home/xsl/models/stable-audio-open/` | 2.8.0+cu128 (stableaudio) | - -**注:全部使用 CUDA 12.8 编译版 torch,支持 RTX 5090 (sm_120)**。 -PyTorch wheel 来源:`https://mirrors.aliyun.com/pytorch-wheels/cu128/` - -#### 启动命令 - -```bash -# Stable Audio Open — Gradio WebUI -conda activate stableaudio -cd /home/xsl/tools/stable-audio-tools -python run_gradio.py \ - --model-config /home/xsl/models/stable-audio-open/model_config.json \ - --ckpt-path /home/xsl/models/stable-audio-open/model.safetensors -``` - -### 下一优先级 — 待部署 - -| 序号 | 工具 | 操作 | 预计耗时 | VRAM | -|------|------|------|---------|------| -| 4 | **ChatTTS** | `pip install ChatTTS` → 下载模型 | 30min | 4GB | - -### 按需部署 - -| 序号 | 工具 | 场景 | -|------|------|------| -| 5 | **DiffRhythm 2** | 需要快速迭代大量 BGM 候选时 | -| 6 | **AudioLDM 2** | 需要精确短音效生成时 | -| 7 | **YuE** | 需要完整中文歌曲(人声+伴奏)时 | -| 8 | **OpenVoice V2** | 需要快速跨语言声音克隆时 | - ---- - -## 四、能力矩阵 - -``` - 本机已有 待部署 线上服务 - ┌───────────────┐ ┌────────────┐ ┌────────────┐ - TTS │ VoxCPM2 │ │ ChatTTS │ │ MiniMax │ - 语音合成 │ CosyVoice2 │ │ F5-TTS │ │ 豆包 TTS │ - │ Kokoro-82M │ │ │ │ ElevenLabs │ - │ │ │ │ │ 通义 TTS │ - └───────────────┘ └────────────┘ └────────────┘ - ┌───────────────┐ ┌────────────┐ ┌────────────┐ - 音乐生成 │ ACE-Step │ │ DiffRhythm │ │ Suno v5 │ - │ │ │ YuE │ │ Udio │ - │ │ │ MusicGen │ │ │ - └───────────────┘ └────────────┘ └────────────┘ - ┌───────────────┐ ┌────────────┐ ┌────────────┐ - 音效生成 │ ACE-Step │ │ AudioLDM 2 │ │ ElevenLabs │ - │ StableAudio ✅│ │ │ │ StableAudio│ - │ Web Audio │ │ │ │ API │ - │ ffmpeg │ │ │ │ │ - └───────────────┘ └────────────┘ └────────────┘ - ┌───────────────┐ ┌────────────┐ ┌────────────┐ - 声音克隆 │ VoxCPM2 │ │ Seed-VC │ │ ElevenLabs │ - │ CosyVoice2 │ │ OpenVoice │ │ MiniMax │ - │ │ │ RVC v2 │ │ 豆包 │ - └───────────────┘ └────────────┘ └────────────┘ - ┌───────────────┐ - ASR │ SenseVoice │ (辅助能力,非生产主线) - 语音识别 │ Whisper │ - └───────────────┘ -``` - ---- - -## 五、VRAM 并行策略 - -RTX 5090 (32GB) 可同时运行的组合: - -| 组合 | 占用 VRAM | 场景 | -|------|----------|------| -| VoxCPM2 (6G) + ACE-Step (8G) | ~14GB | TTS + 音乐同时生产 | -| VoxCPM2 (6G) + Stable Audio (12G) | ~18GB | TTS + 高品质音效 | -| CosyVoice2 (8G) + ChatTTS (4G) | ~12GB | 双 TTS 引擎对比试听 | -| 单独 YuE 量化版 | ~8-24GB | 完整歌曲生成(独占) | - ---- - -## 六、针对《林夏》项目的升级建议 - -| 现状 | 升级方案 | 收益 | -|------|---------|------| -| v5 克隆已部署,待 playtest | 实机听感迭代 → 微调 `voice_style_prompts.py` 或重选 v4 参考 | 各角色区分度与情感准确度 | -| ACE-Step 生成叙事音效 | → Stable Audio Open 1.5 补充精细音效 | 44.1kHz 立体声,电话底噪/UI 音效品质更高 | -| 结局仅语音 + 字幕 | → ACE-Step/DiffRhythm 生成结局专属 BGM | 5 个结局各有独立氛围音乐,沉浸感提升 | - ---- - -## 七、文档索引(保持一致性) - -| 文档 | 用途 | -|------|------| -| `林夏.md` §7 | 设计纲要:资产分类、角色配置、文件结构 | -| `执行文档_音频素材制作.md` | 操作步骤:环境、流程、后期、QA | -| `audio_2/项目状态说明.md` | **现行状态**:端口、目录、命令、选定音色表 | -| `audio_2/角色语音风格提示词.md` | Voice Design / 表演修饰参考 | -| `audio/batch_tts_voxcpm.py` | 台词表 `LINES` + `ROLES` 语速 |