diff --git a/audio/batch_tts_voxcpm.py b/audio/batch_tts_voxcpm.py
index a6f84c8..83d425e 100644
--- a/audio/batch_tts_voxcpm.py
+++ b/audio/batch_tts_voxcpm.py
@@ -421,6 +421,30 @@ LINES = [
("v2_linxia_generic_cold_fine", "linxia", "行吧。"),
("v2_linxia_generic_curious", "linxia", "嗯?"),
("v2_linxia_generic_then", "linxia", "然后呢?"),
+
+ # ══════════════════════════════════════════════════════════
+ # v2 收尾:结局旁白 + 系统提示 + 分支补全
+ # ══════════════════════════════════════════════════════════
+
+ # ── 结局旁白(林夏轻声自语) ──
+ ("v2_ending_a_linxia", "linxia", "明天再说。"),
+ ("v2_ending_b_linxia", "linxia", "寄出去了。"),
+ ("v2_ending_c_linxia", "linxia", "今晚就这样。"),
+ ("v2_ending_d_linxia", "linxia", "早上好。"),
+ ("v2_ending_e_linxia", "linxia", "好。"),
+
+ # ── 系统语音提示(林夏声线) ──
+ ("v2_sys_wait_reply", "linxia", "等待回复。"),
+ ("v2_sys_paused", "linxia", "游戏已暂停。单击继续,三指点击退出。"),
+ ("v2_sys_content_warning", "linxia",
+ "本游戏涉及孤独、家庭关系等情感话题,请在舒适的状态下体验。"),
+ ("v2_sys_hotline", "linxia",
+ "如需倾诉,全国心理援助热线,400-161-9995。"),
+
+ # ── 分支补全:MSG-10/11 沉默路径 NPC 反应 ──
+ ("v2_msg10_rw_react_silent", "mom", "那早点睡啊。"),
+ ("v2_msg10_call_react_down", "mom", "没事就好。早点睡。"),
+ ("v2_msg11_r3_react_silent", "azhe", "行吧,那我不来了。"),
]
diff --git a/audio/convert_new_tts.sh b/audio/convert_new_tts.sh
new file mode 100644
index 0000000..1147e6f
--- /dev/null
+++ b/audio/convert_new_tts.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+# 将新生成的 WAV 文件转为 MP3 并复制到游戏 tts 目录
+set -e
+
+RAW_DIR="/home/xsl/blind/audio/00_raw/tts"
+OUT_DIR="/home/xsl/blind/audio/mp3/tts"
+
+NEW_FILES=(
+ "v2_ending_a_linxia_wav_v1"
+ "v2_ending_b_linxia_wav_v1"
+ "v2_ending_c_linxia_wav_v1"
+ "v2_ending_d_linxia_wav_v1"
+ "v2_ending_e_linxia_wav_v1"
+ "v2_sys_wait_reply_wav_v1"
+ "v2_sys_paused_wav_v1"
+ "v2_sys_content_warning_wav_v1"
+ "v2_sys_hotline_wav_v1"
+ "v2_msg10_rw_react_silent_wav_v1"
+ "v2_msg10_call_react_down_wav_v1"
+ "v2_msg11_r3_react_silent_wav_v1"
+)
+
+ok=0
+fail=0
+
+for name in "${NEW_FILES[@]}"; do
+ src="${RAW_DIR}/${name}.wav"
+ dst="${OUT_DIR}/${name}.mp3"
+
+ if [ ! -f "$src" ]; then
+ echo "[SKIP] $name.wav not found"
+ ((fail++))
+ continue
+ fi
+
+ if [ -f "$dst" ]; then
+ echo "[EXISTS] $name.mp3"
+ ((ok++))
+ continue
+ fi
+
+ echo -n "[CONVERT] $name ... "
+ ffmpeg -y -i "$src" -codec:a libmp3lame -b:a 128k -ar 24000 -ac 1 "$dst" 2>/dev/null
+ if [ $? -eq 0 ]; then
+ echo "ok ($(du -h "$dst" | cut -f1))"
+ ((ok++))
+ else
+ echo "FAILED"
+ ((fail++))
+ fi
+done
+
+echo ""
+echo "Done: $ok ok, $fail failed"
diff --git a/audio/mp3/tts/v2_ending_a_linxia_wav_v1.mp3 b/audio/mp3/tts/v2_ending_a_linxia_wav_v1.mp3
new file mode 100644
index 0000000..a12890b
Binary files /dev/null and b/audio/mp3/tts/v2_ending_a_linxia_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_ending_b_linxia_wav_v1.mp3 b/audio/mp3/tts/v2_ending_b_linxia_wav_v1.mp3
new file mode 100644
index 0000000..ca2bd42
Binary files /dev/null and b/audio/mp3/tts/v2_ending_b_linxia_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_ending_c_linxia_wav_v1.mp3 b/audio/mp3/tts/v2_ending_c_linxia_wav_v1.mp3
new file mode 100644
index 0000000..66df75e
Binary files /dev/null and b/audio/mp3/tts/v2_ending_c_linxia_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_ending_d_linxia_wav_v1.mp3 b/audio/mp3/tts/v2_ending_d_linxia_wav_v1.mp3
new file mode 100644
index 0000000..07f1d4f
Binary files /dev/null and b/audio/mp3/tts/v2_ending_d_linxia_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_ending_e_linxia_wav_v1.mp3 b/audio/mp3/tts/v2_ending_e_linxia_wav_v1.mp3
new file mode 100644
index 0000000..29f23d1
Binary files /dev/null and b/audio/mp3/tts/v2_ending_e_linxia_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_msg10_call_react_down_wav_v1.mp3 b/audio/mp3/tts/v2_msg10_call_react_down_wav_v1.mp3
new file mode 100644
index 0000000..715f703
Binary files /dev/null and b/audio/mp3/tts/v2_msg10_call_react_down_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_msg10_rw_react_silent_wav_v1.mp3 b/audio/mp3/tts/v2_msg10_rw_react_silent_wav_v1.mp3
new file mode 100644
index 0000000..fae1c1f
Binary files /dev/null and b/audio/mp3/tts/v2_msg10_rw_react_silent_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_msg11_r3_react_silent_wav_v1.mp3 b/audio/mp3/tts/v2_msg11_r3_react_silent_wav_v1.mp3
new file mode 100644
index 0000000..f737956
Binary files /dev/null and b/audio/mp3/tts/v2_msg11_r3_react_silent_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_sys_content_warning_wav_v1.mp3 b/audio/mp3/tts/v2_sys_content_warning_wav_v1.mp3
new file mode 100644
index 0000000..959afb2
Binary files /dev/null and b/audio/mp3/tts/v2_sys_content_warning_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_sys_hotline_wav_v1.mp3 b/audio/mp3/tts/v2_sys_hotline_wav_v1.mp3
new file mode 100644
index 0000000..cac7129
Binary files /dev/null and b/audio/mp3/tts/v2_sys_hotline_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_sys_paused_wav_v1.mp3 b/audio/mp3/tts/v2_sys_paused_wav_v1.mp3
new file mode 100644
index 0000000..23dfb63
Binary files /dev/null and b/audio/mp3/tts/v2_sys_paused_wav_v1.mp3 differ
diff --git a/audio/mp3/tts/v2_sys_wait_reply_wav_v1.mp3 b/audio/mp3/tts/v2_sys_wait_reply_wav_v1.mp3
new file mode 100644
index 0000000..d25cffd
Binary files /dev/null and b/audio/mp3/tts/v2_sys_wait_reply_wav_v1.mp3 differ
diff --git a/game/index.html b/game/index.html
index bb6f5b9..0eb54dc 100644
--- a/game/index.html
+++ b/game/index.html
@@ -122,6 +122,14 @@
+
+
+
随时可用
+
两指长按暂停
+
三指点击退出(暂停后)
+
长按播报未读数
@@ -130,6 +138,10 @@
建议关灯 · 戴上耳机
按你的节奏来
+
+ 本游戏涉及孤独、家庭关系等情感话题
+ 请在舒适的状态下体验
+
diff --git a/game/js/engine.js b/game/js/engine.js
index 1230533..6915907 100644
--- a/game/js/engine.js
+++ b/game/js/engine.js
@@ -6,6 +6,7 @@
// 新增状态:
// responding — 林夏说话中(打字音效 + 语音播放)
// reacting — NPC反应播放中
+// paused — 游戏暂停(两指长按触发)
class GameEngine {
constructor() {
@@ -20,6 +21,7 @@ class GameEngine {
ZHOUNAN_SHARE: false,
SELF_RECORD: false,
UNREAD: 0,
+ REPLY_COUNT: 0,
};
this.profile = new ProfileTracker();
@@ -35,6 +37,9 @@ class GameEngine {
this._pendingResolve = null; // 用于中断语音播放的 resolve
this._skipRequested = false; // 跳过标志
+ // 暂停系统
+ this._pausedState = null; // 暂停前的状态
+
this.gest = null;
}
@@ -49,10 +54,11 @@ class GameEngine {
idle: '',
notification: '单击 播放 · 双击 跳过',
ringing: '单击 接听 · 左滑 拒接',
- playing: '双击 跳过',
+ playing: '双击 跳过 · 长按 重播',
choosing: '→温暖 ←冷淡 ↑追问 ↓沉默',
responding: '双击 跳过',
reacting: '双击 跳过',
+ paused: '单击 继续 · 三指点击 退出',
ending: '',
};
el.textContent = H[s] || '';
@@ -65,17 +71,24 @@ class GameEngine {
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('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 });
@@ -334,6 +347,7 @@ class GameEngine {
this.state = 'choosing';
SFX_UI.choiceReady();
Haptic.confirm();
+ Snd.playVoice([`${T}/v2_sys_wait_reply_wav_v1.mp3`]);
this._startReminder();
}
@@ -369,6 +383,7 @@ class GameEngine {
// 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);
@@ -516,6 +531,13 @@ class GameEngine {
_onTap() {
const s = this.state;
+
+ // 暂停中 → 恢复
+ if (s === 'paused') {
+ this._onResume();
+ return;
+ }
+
if (s === 'ringing') {
this._answerCall(this.currentMsg);
} else if (s === 'notification') {
@@ -530,6 +552,8 @@ class GameEngine {
_onDoubleTap() {
const s = this.state;
+ if (s === 'paused') return;
+
if (s === 'ringing') {
this._rejectCall(this.currentMsg);
return;
@@ -579,6 +603,8 @@ class GameEngine {
_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);
@@ -602,6 +628,83 @@ class GameEngine {
}
}
+ // ── 长按:重播 / 状态广播 ──
+ _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);
+ }
+ return;
+ }
+
+ // idle/notification → 广播未读数
+ TTS.speak(`当前未读${this.vars.UNREAD}条消息`);
+ }
+
+ // ── 暂停 ──
+ _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);
+ }
+
// ══════════════════════════════════════════
// 结局系统
// ══════════════════════════════════════════
@@ -611,30 +714,29 @@ class GameEngine {
const v = this.vars;
- // 结局E(隐藏)
+ // 结局E(隐藏)— 优先级 1
if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE &&
(this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) {
return this._triggerEnding('E');
}
- // 结局A:妈妈连接 + 自己录音
+ // 结局A:妈妈连接 + 自己录音 — 优先级 2
if (v.MOM_LINK && v.SELF_RECORD) {
return this._triggerEnding('A');
}
- // 结局B:拒了阿哲 + 回了周南
+ // 结局B:拒了阿哲 + 回了周南 — 优先级 3
if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) {
return this._triggerEnding('B');
}
- // 结局C:全部已读但极少回复
- const replied = Object.values(this.msgState).filter(s => s === 'replied').length;
- const read = Object.values(this.msgState).filter(s => s !== 'unread').length;
- if (read >= MESSAGES.length - 2 && replied <= 2) {
+ // 结局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(兜底)
+ // 结局D(兜底)— 优先级 5
this._triggerEnding('D');
}
@@ -659,6 +761,10 @@ class GameEngine {
E: () => this._endingE(),
}[type];
if (fn) await fn();
+
+ // 结局后播报心理支持热线
+ await sleep(3000);
+ await this._playVoiceAsync([`${T}/v2_sys_hotline_wav_v1.mp3`]);
}
async _endingA() {
@@ -668,13 +774,15 @@ class GameEngine {
await sleep(4000);
SFX_UI.ending();
this._showCaption('明天再说。');
+ await this._playVoiceAsync([`${T}/v2_ending_a_linxia_wav_v1.mp3`]);
}
async _endingB() {
await sleep(2000);
SFX_UI.ending();
await sleep(1000);
- this._showCaption('寄出。');
+ this._showCaption('寄出去了。');
+ await this._playVoiceAsync([`${T}/v2_ending_b_linxia_wav_v1.mp3`]);
}
async _endingC() {
@@ -682,6 +790,7 @@ class GameEngine {
SFX_UI.ending();
await sleep(1000);
this._showCaption('今晚就这样。');
+ await this._playVoiceAsync([`${T}/v2_ending_c_linxia_wav_v1.mp3`]);
}
async _endingD() {
@@ -692,6 +801,7 @@ class GameEngine {
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() {
@@ -704,6 +814,7 @@ class GameEngine {
SFX_UI.ending();
await sleep(1000);
this._showCaption('好。');
+ await this._playVoiceAsync([`${T}/v2_ending_e_linxia_wav_v1.mp3`]);
}
_showCaption(text) {
diff --git a/game/js/gestures.js b/game/js/gestures.js
index ab934d0..8185a57 100644
--- a/game/js/gestures.js
+++ b/game/js/gestures.js
@@ -1,7 +1,8 @@
-// gestures.js — 触控手势检测器(简化版)
+// gestures.js — 触控手势检测器
//
-// 仅支持 6 种手势:单击 · 双击 · 左滑 · 右滑 · 上滑 · 下滑
-// 无长按,无多指操作
+// 支持 9 种手势:
+// 单击 · 双击 · 长按(1s) · 左滑 · 右滑 · 上滑 · 下滑
+// 两指长按(1s) · 三指点击
class GestureDetector {
constructor(el) {
@@ -14,6 +15,15 @@ class GestureDetector {
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();
@@ -30,21 +40,105 @@ class GestureDetector {
// ── 触摸事件 ──
_bindTouch() {
const el = this._el;
+
el.addEventListener('touchstart', e => {
e.preventDefault();
- const t = e.touches[0];
- this._startX = t.clientX;
- this._startY = t.clientY;
- this._startTime = Date.now();
+ 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._reset(), { passive: false });
+ el.addEventListener('touchcancel', () => {
+ this._cancelLongPress();
+ this._cancelMultiTouchLP();
+ this._resetMultiTouch();
+ this._reset();
+ }, { passive: false });
}
// ── 鼠标事件(桌面调试)──
@@ -53,10 +147,31 @@ class GestureDetector {
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();
+ }
+ }
+ });
}
// ── 键盘事件(桌面调试)──
@@ -69,11 +184,14 @@ class GestureDetector {
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;
@@ -114,8 +232,31 @@ class GestureDetector {
}
}
+ // ── 工具方法 ──
+
+ _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/story.js b/game/js/story.js
index 3be5aa6..75d8b2f 100644
--- a/game/js/story.js
+++ b/game/js/story.js
@@ -388,6 +388,11 @@ const MESSAGES = [
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 },
+ },
},
},
},
@@ -415,7 +420,8 @@ const MESSAGES = [
},
down: {
isSilence: true,
- npcReact: [rx('v2_msg10_call_react_silent_wav_v1.mp3')],
+ npcReact: [rx('v2_msg10_call_react_down_wav_v1.mp3')],
+ profile: { avoidance: 1 },
},
},
},
@@ -469,6 +475,12 @@ const MESSAGES = [
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 },
+ },
},
},
},
diff --git a/任务文档_v2收尾.md b/任务文档_v2收尾.md
new file mode 100644
index 0000000..5ccee38
--- /dev/null
+++ b/任务文档_v2收尾.md
@@ -0,0 +1,250 @@
+# 林夏 v2 收尾任务文档
+
+## 一、游戏开发任务
+
+### G1: 手势系统升级 — gestures.js
+
+**现状**: 仅支持 6 种手势(单击/双击/四方向滑动),无长按、无多指手势
+**目标**: 新增长按(1s)、两指长按(暂停)、三指点击(退出)
+
+**交付物**:
+- 长按检测:touchstart 后 1000ms 无移动 → emit `longpress`
+- 两指长按检测:两指同时触屏 1000ms → emit `twofinger_longpress`
+- 三指点击检测:三指同时触屏后抬起 → emit `threefinger_tap`
+- touchmove 监听:移动超阈值取消长按
+- 键盘映射:L=长按, P=暂停, Q=退出
+
+**测试case**:
+1. 单指按住 1.5s 不动 → 触发 longpress,不触发 singletap
+2. 单指按住 0.3s 抬起 → 触发 singletap,不触发 longpress
+3. 单指按住后滑动 → 触发 swipe,不触发 longpress
+4. 双指按住 1.5s → 触发 twofinger_longpress
+5. 双指快速点击(<1s)→ 不触发任何事件(忽略)
+6. 三指同时触屏后抬起 → 触发 threefinger_tap
+7. 键盘 L/P/Q 分别触发对应事件
+
+---
+
+### G2: 变量系统补全 — engine.js
+
+**现状**: 使用 `UNREAD` 变量,缺少 `REPLY_COUNT`
+**目标**: 添加 `REPLY_COUNT`,在玩家做出非沉默选择时递增
+
+**交付物**:
+- `vars` 对象新增 `REPLY_COUNT: 0`
+- `_handleChoice()` 中非沉默选择时 `REPLY_COUNT++`
+- 结局 C 判定改用 `REPLY_COUNT <= 2`
+
+**测试case**:
+1. 选择右滑(温暖) → REPLY_COUNT 递增 1
+2. 选择下滑(沉默, isSilence=true) → REPLY_COUNT 不变
+3. 双击跳过选择 → REPLY_COUNT 不变
+4. 全程极少回复(REPLY_COUNT<=2) + 全部已读 → 触发结局 C
+
+---
+
+### G3: 长按手势处理 — engine.js
+
+**现状**: 无长按处理
+**目标**: playing/choosing 状态重播当前消息音频;其他状态广播未读数
+
+**交付物**:
+- `_onLongPress()` 方法
+- playing/responding/reacting → 中断当前播放,重播 currentMsg.audio
+- choosing → 重播 currentMsg.audio(不退出选择状态)
+- idle/notification → TTS 播报 "当前未读 N 条消息"
+
+**测试case**:
+1. playing 状态长按 → 当前语音重头播放
+2. choosing 状态长按 → 重播 NPC 问题,仍可继续选择
+3. idle 状态长按 → TTS 播报未读数
+4. ending 状态长按 → 无反应
+
+---
+
+### G4: 暂停/退出机制 — engine.js
+
+**现状**: 无暂停/退出功能
+**目标**: 两指长按暂停,单击恢复,三指点击退出
+
+**交付物**:
+- 新状态 `paused`
+- `_onPause()`: 保存当前状态、停止所有音频/震动、TTS 播报暂停提示
+- `_onResume()`: 恢复到 choosing/idle(语音无法断点续播)
+- `_onExit()`: 仅 paused 状态可用,TTS 播报感谢后返回开始页
+- hint 栏显示暂停状态提示
+
+**测试case**:
+1. playing 状态两指长按 → 音频停止,进入 paused,TTS 播报暂停
+2. paused 状态单击 → 恢复游戏
+3. paused 状态三指点击 → 退出到开始页
+4. 非 paused 状态三指点击 → 无反应
+5. ending 状态两指长按 → 无反应(结局不可暂停)
+
+---
+
+### G5: 选择状态语音提示 — engine.js
+
+**现状**: 进入 choosing 仅播放 choiceReady 音效 + confirm 震动
+**目标**: 追加 TTS 播报 "等待回复"
+
+**交付物**:
+- `_enterChoosing()` 中 SFX_UI.choiceReady() 后追加 TTS.speak('等待回复')
+
+**测试case**:
+1. 播放完 NPC 语音进入选择 → 听到 "叮" + TTS "等待回复"
+2. 快速双击跳过 → TTS 被中断,不卡住
+
+---
+
+### G6: 结局语音化 — engine.js
+
+**现状**: 结局使用 `_showCaption(text)` 显示视觉文字(与零视觉设计矛盾)
+**目标**: 结局台词通过 TTS 朗读(后续替换为预录音频)
+
+**交付物**:
+- `_endingA/B/C/D/E` 中 `_showCaption(text)` → `TTS.speak(text)`(保留 caption 作为补充)
+- 结局 E 周南语音播完后 TTS 说 "好"
+
+**测试case**:
+1. 触发结局 A → 听到水壶声 + TTS "明天再说"
+2. 触发结局 D → 听到鸟鸣声 + TTS "早上好"
+3. 触发结局 E → 周南语音 + TTS "好"
+4. TTS 不可用时 → caption 仍然显示(降级方案)
+
+---
+
+### G7: 内容安全 — engine.js + index.html
+
+**现状**: 无内容预警和求助信息
+**目标**: 游戏开始前播报内容预警,结局后显示心理支持热线
+
+**交付物**:
+- `start()` 开头:TTS 播报 "本游戏涉及孤独、家庭关系等情感话题,请在舒适的状态下体验"
+- 结局播完后:TTS 播报 "如需倾诉,全国心理援助热线 400-161-9995"
+- index.html 开始页添加内容预警文字
+
+**测试case**:
+1. 游戏启动 → 先听到内容预警,再开始环境音
+2. 任意结局完成后 → 听到热线播报
+3. 开始页可见内容预警文字
+
+---
+
+### G8: 分支补全 — story.js
+
+**现状**: MSG-10/11 follow-up 缺少沉默(down)选项
+**目标**: 补全所有未闭合分支
+
+**交付物**:
+- MSG-10 right follow-up: 添加 down(沉默) 选项
+- MSG-10 up follow-up: 添加 down(沉默) 选项
+- MSG-11 up follow-up: 添加 down(沉默) 选项
+
+**测试case**:
+1. MSG-10 温暖回复后 → 可选右滑/上滑/下滑(沉默)
+2. MSG-10 追问后通话 → 可选右滑/上滑/下滑(沉默)
+3. MSG-11 追问后 → 可选右滑/左滑/下滑(沉默)
+
+---
+
+### G9: 操作指南更新 — index.html
+
+**现状**: 开始页操作说明缺少长按和安全手势
+**目标**: 补充完整操作说明
+
+**交付物**:
+- 添加 "播放中" 长按重播说明
+- 添加 "随时可用" 安全手势分组(两指长按暂停、三指点击退出)
+
+**测试case**:
+1. 开始页显示完整操作说明,含长按和安全手势
+
+---
+
+## 二、音频制作任务(需 VoxCPM2 服务)
+
+### A1: 结局旁白音频
+
+**现状**: 结局台词无预录音频,依赖 TTS
+**目标**: 用林夏声线生成 5 条结局旁白
+
+**交付物** (5 个 MP3):
+| 文件名 | 台词 |
+|---------|------|
+| `v2_ending_a_linxia_wav_v1.mp3` | "明天再说。" |
+| `v2_ending_b_linxia_wav_v1.mp3` | "寄出去了。" |
+| `v2_ending_c_linxia_wav_v1.mp3` | "今晚就这样。" |
+| `v2_ending_d_linxia_wav_v1.mp3` | "早上好。" |
+| `v2_ending_e_linxia_wav_v1.mp3` | "好。" |
+
+**测试case**:
+1. 每个文件 < 3s,采样率 24kHz+,音色一致
+2. 语气匹配情境(A 疲惫温柔、D 平静、E 轻快)
+
+---
+
+### A2: 系统提示音频
+
+**现状**: 无系统提示预录音频
+**目标**: 生成游戏流程中的系统语音提示
+
+**交付物** (4 个 MP3):
+| 文件名 | 台词 |
+|---------|------|
+| `v2_sys_wait_reply_wav_v1.mp3` | "等待回复" |
+| `v2_sys_paused_wav_v1.mp3` | "游戏已暂停。单击继续,三指点击退出" |
+| `v2_sys_content_warning_wav_v1.mp3` | "本游戏涉及孤独、家庭关系等情感话题,请在舒适的状态下体验" |
+| `v2_sys_hotline_wav_v1.mp3` | "如需倾诉,全国心理援助热线 400-161-9995" |
+
+**测试case**:
+1. 音色使用林夏声线,语速适中
+2. 暂停提示 < 5s,内容预警 < 8s
+
+---
+
+### A3: 新增分支台词音频
+
+**现状**: MSG-10/11 补全的 down 分支缺音频
+**目标**: 生成沉默后 NPC 反应音频
+
+**交付物** (3 个 MP3):
+| 文件名 | 角色 | 台词 |
+|---------|------|------|
+| `v2_msg10_rw_react_silent_wav_v1.mp3` | 妈妈 | "那早点睡啊。" |
+| `v2_msg10_call_react_down_wav_v1.mp3` | 妈妈 | "……没事就好。早点睡。" |
+| `v2_msg11_r3_react_silent_wav_v1.mp3` | 阿哲 | "行吧,那我不来了。" |
+
+**测试case**:
+1. 音色与对应角色现有音频一致
+2. 语气自然,符合上下文
+
+---
+
+### A4: MSG-16 后期处理
+
+**现状**: seg5-6 是奶奶无声电话的描述段,需要电话底噪叠加
+**目标**: 与 MSG-08 未知号码呼吸声使用匹配的底噪
+
+**交付物**:
+- `lv11_0200_mom_voice_01_seg5_wav_v1.mp3` 叠加电话底噪
+- `lv11_0200_mom_voice_01_seg6_wav_v1.mp3` 叠加电话底噪
+
+**测试case**:
+1. seg5-6 可听到轻微电话底噪(与 MSG-08 sfx_breathing_unknown_v1.mp3 风格匹配)
+2. 底噪不盖过妈妈说话声(底噪 -18dB 以下)
+
+---
+
+## 三、执行顺序
+
+```
+阶段一(代码 — 立即执行):
+ G1 → G2 → G3/G4/G5 → G6 → G7 → G8 → G9
+
+阶段二(音频 — 需 VoxCPM2):
+ A1 → A2 → A3 → A4
+
+阶段三(集成):
+ 结局音频替换 TTS → 系统提示音替换 TTS → 回归测试
+```
diff --git a/执行文档_音频素材制作.md b/执行文档_音频素材制作.md
index c173295..51ac339 100644
--- a/执行文档_音频素材制作.md
+++ b/执行文档_音频素材制作.md
@@ -1,7 +1,9 @@
# 《林夏》音频素材本地制作执行文档
> **前置文档**:`林夏.md`(游戏设计)、`SOP_AI音频生产.md`(生产规范)
-> **执行环境**:本机 WSL2 · RTX 5090 32GB · CosyVoice2-0.5B · FFmpeg 7.0
+> **执行环境**:本机 WSL2 · RTX 5090 32GB · FFmpeg 7.0
+> **TTS 主链路**:**VoxCPM2**(见 `林夏.md` §7.2),批量脚本 `audio/batch_tts_voxcpm.py`
+> **TTS 备用**:CosyVoice2-0.5B(见附录 B)
> **目标**:按 SOP 规范在本地生产全部音频素材
---
@@ -10,12 +12,14 @@
| 资源 | 路径 / 状态 | 用途 |
|---|---|---|
-| CosyVoice 2.0 (0.5B) | `/home/xsl/tools/CosyVoice` · conda env: `cosyvoice` | TTS 主力(替代 SOP 中 MiniMax/豆包方案) |
+| **VoxCPM2** | `bash /home/xsl/AutoVideo/start-voxcpm.sh` → `http://127.0.0.1:8000` | **TTS 主链路**(声音克隆 + 合成) |
+| CosyVoice 2.0 (0.5B) | `/home/xsl/tools/CosyVoice` · conda env: `cosyvoice` | TTS 备用(见附录 B) |
| FFmpeg 7.0 | `/usr/local/bin/ffmpeg` | 音频格式转换、响度归一化、听筒效果 |
-| GPU | RTX 5090 32GB | 跑 CosyVoice 推理 |
+| GPU | RTX 5090 32GB | 跑 VoxCPM2 / CosyVoice 推理 |
| MuseTalk | `/home/xsl/work/MuseTalk` | **注意:这是唇形同步工具,不是 TTS** |
-> **SOP 中的 MiniMax / 豆包 API 全部替换为本地 CosyVoice**。音乐(Suno)、音效(ElevenLabs SFX)、环境采样(Freesound)仍走云端。
+> **SOP 中的 MiniMax / 豆包 API 全部替换为本地 VoxCPM2**。音乐(Suno)、音效(ElevenLabs SFX)、环境采样(Freesound)仍走云端。
+> CosyVoice2 保留作为备用方案(见附录 B),仅在 VoxCPM2 表现不佳时切换。
---
@@ -26,51 +30,27 @@
```bash
cd /home/xsl/blind
mkdir -p audio/{00_raw/{tts,music,sfx,ambience,ringtone},01_processed/{tts,music,sfx,ambience,ringtone},02_final/{tts,music,sfx,ambience,ringtone},03_qa_reports,99_legal}
-mkdir -p audio/00_raw/tts/prompt_ref # 存放角色参考音频
+mkdir -p voice/{linxia,mom,azhe,xiaomei,zhounan,hr,anan,dorm_a,dorm_b,dorm_c,delivery,system} # 各角色参考音频
```
-### 1.2 验证 CosyVoice 环境
+### 1.2 启动 VoxCPM2 服务
```bash
-# 激活 conda 环境
-conda activate cosyvoice
-
-# 验证依赖
-cd /home/xsl/tools/CosyVoice
-python -c "
-import sys
-sys.path.append('third_party/Matcha-TTS')
-from cosyvoice.cli.cosyvoice import CosyVoice2
-model = CosyVoice2('pretrained_models/CosyVoice2-0.5B')
-print('可用预训练音色:', model.list_available_spks())
-print('采样率:', model.sample_rate)
-print('CosyVoice2 加载成功')
-"
+# 启动 VoxCPM2 TTS 服务
+bash /home/xsl/AutoVideo/start-voxcpm.sh
+# uvicorn 服务: http://127.0.0.1:8000
```
-如果报错 `ModuleNotFoundError`,先装依赖:
+验证服务可用:
```bash
-conda activate cosyvoice
-cd /home/xsl/tools/CosyVoice
-pip install -r requirements.txt
+curl -s http://127.0.0.1:8000/docs | head -5
+# 应返回 API 文档页面
```
-### 1.3 启动 CosyVoice WebUI
-
-```bash
-conda activate cosyvoice
-cd /home/xsl/tools/CosyVoice
-python webui.py --port 8000
-```
-
-浏览器访问 `http://localhost:8000`。WebUI 提供 4 种推理模式:
-
-| 模式 | 说明 | 本项目用途 |
-|---|---|---|
-| **预训练音色** | 从内置音色列表选择 | 快速试听、系统 TTS |
-| **3s极速复刻** | 提供 3-30s 参考音频 + 对应文字 | **主力模式**:为每个角色准备参考音频 |
-| **跨语种复刻** | 跨语言克隆 | 暂不需要 |
-| **自然语言控制** | 用文字描述控制语气风格 | CosyVoice2 不支持此模式,改用 instruct2 API |
+**VoxCPM2 API 接口**(详见 `林夏.md` §7.2):
+- `POST /v1/voices` — 注册参考音频(wav_base64)→ 返回 voice_id
+- `POST /v1/speech` — 合成语音(text + voice_id)→ 返回 WAV
+- 输出格式:48kHz 16-bit PCM WAV
### 1.4 验证 FFmpeg
@@ -87,7 +67,7 @@ echo "FFmpeg loudnorm 可用"
## 2. 角色参考音频准备(关键步骤)
-CosyVoice2 的"3s极速复刻"需要**每个角色一段 5-20 秒的参考音频**。参考音频的音色决定了最终 TTS 的音色。
+VoxCPM2 的声音克隆需要**每个角色一段参考音频**(WAV 格式)。参考音频的音色决定了最终 TTS 的音色。参考音频存放在 `voice/{角色}/` 目录下(详见 `林夏.md` §7.3 角色音色配置)。
### 2.1 参考音频来源方案
@@ -101,7 +81,7 @@ CosyVoice2 的"3s极速复刻"需要**每个角色一段 5-20 秒的参考音频
| `anan` 安安 | 23 岁,可爱 | 年轻女性活泼语调截取 |
| `dorm-a/b/c` | 三个不同年轻女声 | 需要 3 段风格不同的女声参考 |
| `delivery` 外卖小哥 | 30 岁,北方腔 | 短视频/影视截取 |
-| `system` 系统 | 中性 AI 女声 | 用 CosyVoice 预训练音色即可 |
+| `system` 系统 | 中性 AI 女声 | VoxCPM2 注册一个中性女声参考音频 |
| `self-3y` 3年前自己 | 同 xiaomei 基底 | 复用 xiaomei 参考音频,调速度和音调 |
### 2.2 参考音频制作规格
@@ -111,229 +91,80 @@ CosyVoice2 的"3s极速复刻"需要**每个角色一段 5-20 秒的参考音频
- **内容**:完整的中文句子(需同时记录对应文字)
- **质量**:干声为主,避免底噪、混响、BGM
-存放路径:`audio/00_raw/tts/prompt_ref/{角色代号}_ref.wav`
+存放路径:`voice/{角色代号}/`(与 `林夏.md` §7.3 保持一致)
```bash
# 如果参考音频不是 wav 或采样率不对,用 FFmpeg 转换
-ffmpeg -i input.mp3 -ar 16000 -ac 1 audio/00_raw/tts/prompt_ref/mom_ref.wav
+ffmpeg -i input.mp3 -ar 16000 -ac 1 voice/mom/ref.wav
```
### 2.3 创建参考音频记录表
-在 `audio/00_raw/tts/prompt_ref/` 下创建 `ref_manifest.txt`,记录每条参考音频对应的文字:
+在 `voice/` 下创建 `ref_manifest.txt`,记录每条参考音频对应的文字:
```
-mom_ref.wav | 今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。
-azhe_ref.wav | 好的,我知道了,那件事明天再说吧。
-xiaomei_ref.wav | 哎你猜怎么着,今天路上遇到一个超级搞笑的事!
-zhounan_ref.wav | 嗯,其实我也不太确定,就是觉得应该跟你说一声。
-hr_ref.wav | 好的,那我们下周三前把流程走完,有什么问题随时联系我。
-anan_ref.wav | 天呐天呐你快来!这边超级好玩的!
-dorm_a_ref.wav | 嘿你们看看我,今天升职了!请大家吃饭!
-dorm_b_ref.wav | 恭喜恭喜啊,太厉害了,我就知道你行的。
-dorm_c_ref.wav | 嗯,不错,对了夏夏怎么不说话啊。
-delivery_ref.wav | 你好你的外卖到了,放门口了啊,麻辣烫注意别洒了。
+mom/ref.wav | 今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。
+azhe/ref.wav | 好的,我知道了,那件事明天再说吧。
+xiaomei/ref.wav | 哎你猜怎么着,今天路上遇到一个超级搞笑的事!
+zhounan/ref.wav | 嗯,其实我也不太确定,就是觉得应该跟你说一声。
+hr/ref.wav | 好的,那我们下周三前把流程走完,有什么问题随时联系我。
+anan/ref.wav | 天呐天呐你快来!这边超级好玩的!
+dorm_a/ref.wav | 嘿你们看看我,今天升职了!请大家吃饭!
+dorm_b/ref.wav | 恭喜恭喜啊,太厉害了,我就知道你行的。
+dorm_c/ref.wav | 嗯,不错,对了夏夏怎么不说话啊。
+delivery/ref.wav | 你好你的外卖到了,放门口了啊,麻辣烫注意别洒了。
```
---
## 3. TTS 生产流程
-### 3.1 用 WebUI 逐条生产(推荐新手)
+### 3.1 VoxCPM2 声音克隆流程
-1. 浏览器打开 `http://localhost:8000`
-2. 选择模式:**3s极速复刻**
-3. 上传对应角色的参考音频
-4. 在"prompt文本"中输入参考音频对应的文字
-5. 在"合成文本"中输入要合成的台词
-6. 调整速度(对应 SOP §4.2 各角色 Speed 值)
-7. 点击"生成音频"
-8. 试听满意后,右键保存音频
+**前置**:确认 VoxCPM2 服务已启动(§1.2)。
-### 3.2 用 Python 脚本批量生产(推荐熟练后)
+**单条合成步骤**:
+1. 将角色参考音频注册为 voice_id:`POST /v1/voices`(wav_base64)
+2. 调用合成接口:`POST /v1/speech`(text + voice_id)
+3. 获得 48kHz 16-bit PCM WAV
+4. 用 FFmpeg atempo 调速(按 `林夏.md` §7.3 各角色语速配置)
+5. 保存到 `audio/00_raw/tts/`
-创建批量生产脚本 `audio/batch_tts.py`:
-
-```python
-#!/usr/bin/env python3
-"""
-《林夏》TTS 批量生产脚本
-用法: conda activate cosyvoice && python audio/batch_tts.py
-"""
-import os, sys, json, torch, torchaudio
-
-# 设置路径
-COSYVOICE_DIR = "/home/xsl/tools/CosyVoice"
-PROJECT_DIR = "/home/xsl/blind"
-sys.path.insert(0, COSYVOICE_DIR)
-sys.path.insert(0, f"{COSYVOICE_DIR}/third_party/Matcha-TTS")
-
-from cosyvoice.cli.cosyvoice import CosyVoice2
-from cosyvoice.utils.file_utils import load_wav
-
-# 加载模型
-print("正在加载 CosyVoice2 模型...")
-model = CosyVoice2(f"{COSYVOICE_DIR}/pretrained_models/CosyVoice2-0.5B")
-print(f"模型加载完成,采样率: {model.sample_rate}")
-
-# ============================================================
-# 角色配置 — 按 SOP §4.2 配置卡填写
-# speed: 对应 SOP 中的 Speed 值
-# ref_wav: 参考音频路径
-# ref_text: 参考音频对应文字
-# ============================================================
-ROLES = {
- "mom": {
- "speed": 0.92,
- "ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/mom_ref.wav",
- "ref_text": "今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。",
- },
- "azhe": {
- "speed": 1.05,
- "ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/azhe_ref.wav",
- "ref_text": "好的,我知道了,那件事明天再说吧。",
- },
- "xiaomei": {
- "speed": 1.10,
- "ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/xiaomei_ref.wav",
- "ref_text": "哎你猜怎么着,今天路上遇到一个超级搞笑的事!",
- },
- "zhounan": {
- "speed": 0.95,
- "ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/zhounan_ref.wav",
- "ref_text": "嗯,其实我也不太确定,就是觉得应该跟你说一声。",
- },
- "hr": {
- "speed": 1.00,
- "ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/hr_ref.wav",
- "ref_text": "好的,那我们下周三前把流程走完,有什么问题随时联系我。",
- },
- "anan": {
- "speed": 1.15,
- "ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/anan_ref.wav",
- "ref_text": "天呐天呐你快来!这边超级好玩的!",
- },
- "delivery": {
- "speed": 1.20,
- "ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/delivery_ref.wav",
- "ref_text": "你好你的外卖到了,放门口了啊,麻辣烫注意别洒了。",
- },
- # system 用预训练音色,不走 zero-shot
-}
-
-# ============================================================
-# 台词表 — 从林夏.md §3 / §5 抄写
-# 每条格式: (文件名前缀, 角色, 台词, speed_override=None)
-# ============================================================
-LINES = [
- # === 第 01 条 · 21:03 妈妈电话 ===
- ("lv11_2103_mom_call_01", "mom",
- "今晚煮了你爱吃的排骨,给你拍张照啊。下周五回来一趟,你高中物理老师的儿子,挺好的。"),
-
- # === 第 02 条 · 21:15 小美三连 ===
- ("lv11_2115_xiaomei_voice_01", "xiaomei", "在吗"),
- ("lv11_2115_xiaomei_voice_02", "xiaomei", "你被甩了我都听说了"),
- ("lv11_2115_xiaomei_voice_03", "xiaomei", "明天晚上喝酒,海底捞旁边那家,不许放鸽子"),
-
- # === 第 03 条 · 21:20 外卖小哥 ===
- ("lv11_2120_delivery_call_01", "delivery",
- "你好你的麻辣烫到了,放门口可以吗?"),
-
- # === 第 04 条 · 21:30 阿哲语音 ===
- ("lv11_2130_azhe_voice_01", "azhe",
- "嗯……夏夏。我那个AirPods充电盒,好像落你抽屉里了。你方便的时候帮我寄一下吗,工位还要用。地址我等下发你。"),
-
- # === 第 05 条 · 21:45 HR 王姐 ===
- ("lv11_2145_hr_voice_01", "hr",
- "夏夏啊,离职流程你下周三之前办完哈,公积金那边记得跟进。辛苦啦,外面有合适的工作我帮你留意。"),
-
- # === 第 06 条 · 22:00 安安 ===
- ("lv11_2200_anan_voice_01", "anan",
- "夏夏!我在helens!要不要过来!我请客!"),
-
- # === 第 10 条 · 23:00 妈妈文字 ===
- ("lv11_2300_mom_text_01", "mom", "睡了吗?"),
-
- # === 第 11 条 · 23:15 阿哲电话 ===
- ("lv11_2315_azhe_call_01", "azhe",
- "我能过去拿吗?就5分钟"),
-
- # === 第 12 条 · 23:30 周南 ===
- ("lv11_2330_zhounan_voice_01", "zhounan",
- "林夏,是我,周南。你应该不记得我了吧,初三坐你后桌那个"),
-
- # === 第 14 条 · 00:15 妈妈电话 ===
- ("lv11_0015_mom_call_01", "mom",
- "夏夏,你不接电话我有点担心。妈妈知道你工作忙,你早点睡。"),
-
- # ... 更多台词按需添加
- # 长戏(妈妈 4'17"、小美醉语音、周南 4 段)需分段处理,见 §3.3
-]
-
-
-def synthesize_line(filename, role, text, speed_override=None):
- """合成单条台词"""
- cfg = ROLES[role]
- speed = speed_override or cfg["speed"]
- ref_wav = cfg["ref_wav"]
- ref_text = cfg["ref_text"]
-
- if not os.path.exists(ref_wav):
- print(f" [跳过] 参考音频不存在: {ref_wav}")
- return
-
- # 加载参考音频
- prompt_speech = load_wav(ref_wav, 16000)
- # 裁剪到合理长度
- if prompt_speech.shape[1] > 16000 * 30:
- prompt_speech = prompt_speech[:, :16000 * 30]
-
- output_path = f"{PROJECT_DIR}/audio/00_raw/tts/{filename}_v1.wav"
- print(f" 合成中: {filename} [{role}] speed={speed}")
- print(f" 台词: {text[:50]}...")
-
- speeches = []
- for chunk in model.inference_zero_shot(
- tts_text=text,
- prompt_text=ref_text,
- prompt_speech_16k=prompt_speech,
- stream=False,
- speed=speed,
- ):
- speeches.append(chunk["tts_speech"])
-
- if speeches:
- full_speech = torch.concat(speeches, dim=1)
- torchaudio.save(output_path, full_speech, model.sample_rate)
- duration = full_speech.shape[1] / model.sample_rate
- print(f" 已保存: {output_path} ({duration:.1f}s)")
- else:
- print(f" [失败] 未生成音频: {filename}")
-
-
-def main():
- print(f"\n{'='*60}")
- print(f" 《林夏》TTS 批量生产")
- print(f" 共 {len(LINES)} 条台词")
- print(f"{'='*60}\n")
-
- for i, line in enumerate(LINES):
- filename = line[0]
- role = line[1]
- text = line[2]
- speed_override = line[3] if len(line) > 3 else None
-
- print(f"\n[{i+1}/{len(LINES)}] {filename}")
- synthesize_line(filename, role, text, speed_override)
-
- print(f"\n{'='*60}")
- print(" 批量合成完成!请到 audio/00_raw/tts/ 检查结果")
- print(f"{'='*60}")
-
-
-if __name__ == "__main__":
- main()
+**角色音色目录**(与 `林夏.md` §7.3 一致):
```
+voice/
+├── linxia/ # 林夏(speed 1.00)
+├── mom/ # 妈妈(speed 0.92)
+├── azhe/ # 阿哲(speed 1.05)
+├── xiaomei/ # 小美(speed 1.10,醉态 0.95)
+├── zhounan/ # 周南(speed 0.95)
+├── hr/ # HR 王姐(speed 1.00)
+├── anan/ # 安安(speed 1.15)
+├── dorm_a/ # 室友 A(speed 1.10)
+├── dorm_b/ # 室友 B(speed 1.00)
+├── dorm_c/ # 室友 C(speed 0.95)
+└── delivery/ # 外卖小哥(speed 1.20)
+```
+
+### 3.2 用批量脚本生产(主力方式)
+
+使用项目自带的 VoxCPM2 批量合成脚本:
+
+```bash
+# 确保 VoxCPM2 服务已启动
+bash /home/xsl/AutoVideo/start-voxcpm.sh
+
+# 批量合成所有台词
+python audio/batch_tts_voxcpm.py --source voice
+
+# 只合成特定角色
+python audio/batch_tts_voxcpm.py --source voice --role linxia
+
+# 干跑查看任务列表
+python audio/batch_tts_voxcpm.py --source voice --dry-run
+```
+
+脚本读取 `voice/` 目录下的参考音频,按 `林夏.md` §7.3 角色配置自动注册 voice_id 并批量合成。输出到 `audio/00_raw/tts/`。
### 3.3 长戏分段处理
@@ -360,11 +191,11 @@ SOP §5.3 规定:**超过 30 秒的台词必须分段录制**。
### 3.4 系统 TTS
-系统提示音(播报未读消息、念选项等)用 CosyVoice **预训练音色**模式:
+系统提示音(播报状态、念选项等)用 VoxCPM2 注册一个中性女声参考音频:
-1. WebUI 选"预训练音色"
-2. 从下拉菜单选一个清晰的中性女声
-3. 合成所有系统语句
+1. 准备一段清晰的中性女声参考音频(如新闻播报片段),存入 `voice/system/`
+2. 通过 `POST /v1/voices` 注册为 system voice_id
+3. 用 `POST /v1/speech` 合成所有系统语句
系统语句示例:
- "阿哲,发来1条语音。8秒。"
@@ -663,13 +494,13 @@ done
## 9. 执行顺序总结
```
-第 1 步 · 部署环境 (§1)
+第 1 步 · 启动 VoxCPM2 + 部署环境 (§1)
↓ 约 30 分钟
-第 2 步 · 准备参考音频 (§2) ← 这一步最关键,音色质量取决于此
+第 2 步 · 准备参考音频到 voice/ (§2) ← 这一步最关键,音色质量取决于此
↓ 约 1-3 天(需要找人录音或搜集素材)
-第 3 步 · 系统 TTS 先行 (§3.4) ← 最简单的先跑通流程
+第 3 步 · 系统 TTS 先行 (§3.4) ← 最简单的先跑通 VoxCPM2 流程
↓ 约 1 小时
-第 4 步 · 短台词批量生产 (§3.1/3.2)
+第 4 步 · 短台词批量生产 (§3.2 batch_tts_voxcpm.py)
↓ 约 3-4 小时
第 5 步 · 长戏分段录制 (§3.3)
↓ 约 2-3 小时
@@ -692,22 +523,57 @@ done
## 附录 A · 常见问题
-**Q: CosyVoice 生成的语音有电流声/杂音?**
+**Q: VoxCPM2 生成的语音有电流声/杂音?**
A: 检查参考音频质量。参考音频必须干净无噪,采样率 ≥ 16kHz。
**Q: 语速不自然?**
-A: speed 参数范围 0.5-2.0。如果调速后音质下降,改为在合成文本中加标点控制节奏(句号=长停顿,逗号=短停顿)。
+A: 用 FFmpeg atempo 调速(范围 0.5-2.0)。如果调速后音质下降,改为在合成文本中加标点控制节奏(句号=长停顿,逗号=短停顿)。
**Q: 怎样让同一个音色听起来"醉了"?**
-A: CosyVoice 没有情绪控制参数。替代方案:
+A: TTS 通常没有情绪控制参数。替代方案:
1. 在文本中插入省略号、重复字来模拟口齿不清
2. 后期用 FFmpeg 轻微变速+加混响:
```bash
ffmpeg -i drunk.wav -af "atempo=0.95,aecho=0.8:0.7:40:0.3" -y drunk_fx.wav
```
-**Q: WebUI 启动报 CUDA 错误?**
-A: 确认 conda env 正确:`conda activate cosyvoice`。确认 GPU 可用:`nvidia-smi`。
+**Q: VoxCPM2 服务无法启动?**
+A: 确认 `bash /home/xsl/AutoVideo/start-voxcpm.sh` 正常。确认 GPU 可用:`nvidia-smi`。
**Q: 生成很慢?**
-A: RTX 5090 应该很快(远超实时)。如果慢,检查是否误用了 CPU。在 Python 中加 `print(torch.cuda.is_available())` 确认。
+A: RTX 5090 应该很快(远超实时)。如果慢,检查是否误用了 CPU。
+
+---
+
+## 附录 B · CosyVoice2 备用方案
+
+> **仅在 VoxCPM2 表现不佳或不可用时使用。** 正常生产流程请走 §1-§3 的 VoxCPM2 主链路。
+
+### B.1 环境验证
+
+```bash
+conda activate cosyvoice
+cd /home/xsl/tools/CosyVoice
+python -c "
+import sys
+sys.path.append('third_party/Matcha-TTS')
+from cosyvoice.cli.cosyvoice import CosyVoice2
+model = CosyVoice2('pretrained_models/CosyVoice2-0.5B')
+print('CosyVoice2 加载成功,采样率:', model.sample_rate)
+"
+```
+
+### B.2 WebUI 启动
+
+```bash
+conda activate cosyvoice
+cd /home/xsl/tools/CosyVoice
+python webui.py --port 8001 # 注意:用 8001 避免与 VoxCPM2 的 8000 端口冲突
+```
+
+模式选择:**3s极速复刻**(上传参考音频 + 对应文字 → 合成)。
+
+### B.3 批量脚本
+
+CosyVoice2 版批量脚本为 `audio/batch_tts.py`(区别于主力脚本 `audio/batch_tts_voxcpm.py`)。
+用法:`conda activate cosyvoice && python audio/batch_tts.py`
diff --git a/林夏.md b/林夏.md
index e8b9ffa..3b85b09 100644
--- a/林夏.md
+++ b/林夏.md
@@ -10,7 +10,7 @@
## 0. 一句话概述
-24 岁的林夏,被分手、被裁员、被妈妈催婚,全发生在同一个周五。她把手机调成静音、躲进合租屋,关上灯。屏幕坏了——只剩耳机能听、手指能摸。今晚 16 个人会发来电话和语音。她接谁、不接谁、怎么回——每一个回应都会被对方听到,每一段对话都会走向不同的结局。
+24 岁的林夏,被分手、被裁员、被妈妈催婚,全发生在同一个周五。她把手机调成静音、躲进合租屋,关上灯。屏幕坏了——只剩耳机能听、手指能摸。今晚会有 16 条电话和语音陆续到达。她接谁、不接谁、怎么回——每一个回应都会被对方听到,每一段对话都会走向不同的结局。
---
@@ -39,18 +39,21 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
## 2. 核心玩法
-### 2.1 六种手势——零视觉依赖
+### 2.1 七种手势——零视觉依赖
| 手势 | 全局含义 | 情感色彩 | 触觉反馈 | 音效 |
|------|---------|---------|---------|------|
| **单击** | 接受 / 回应 / 继续 | 中性应答 | 轻触感 | 清脆短音 880Hz |
| **双击** | 跳过 / 忽略 | 不耐烦 / 赶时间 | 短促双震 | 下行滑音 587→392Hz |
+| **长按(1s)** | 根据状态:重听或播报 | 确认 | 缓慢长震 | 低沉确认音 330→440Hz |
| **右滑** | 温暖 / 靠近 | 接纳、关心 | 流畅长震 | 上行音 440→659Hz |
| **左滑** | 冷淡 / 推开 | 疏离、敷衍 | 干脆短震 | 下行音 523→349Hz |
| **上滑** | 好奇 / 追问 | 想了解更多 | 快速上弹震 | 快速上行 523→784Hz |
| **下滑** | 沉默 / 退缩 | 说不出口 | 极轻慢震 | 极轻低音 330Hz |
> **不需要记位置**。每个方向有固定的情感含义,贯穿全游戏。右滑永远是温暖,左滑永远是冷淡。
+>
+> **长按随时可用**:任何状态下长按 1 秒,系统根据当前状态做出响应——在 `playing` 或 `choosing` 状态下重播上一句 NPC 语音,其他状态下语音播报当前位置(例如"小美的消息,等待回复")。这是玩家在纯音频环境下的"安全网"。
### 2.2 对话流程(多轮交互)
@@ -67,8 +70,11 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
└──────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────┐
- │ [三音上行提示音 · 等待选择] │
+ │ ★ 进入选择态 │
+ │ [三音上行提示音 + 明确震动模式(长-短-长)] │
+ │ 系统语音提示:"等待回复" │
│ 玩家操作:右滑 / 左滑 / 上滑 / 下滑 / 单击 │
+ │ 长按 → 重听 NPC 刚才说的话 │
└──────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────┐
@@ -132,7 +138,7 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
| 07 | 寝室群 | 语音 ×5 | 室友 A 升职,@林夏 | 5 方向 + 温暖 Round 3 |
| 08 | 未知号码 | 电话 | 呼吸声 6 秒后挂断 | 无选择(悬疑钩子) |
| 09 | 小美(醉)| 语音 ×4 | "阿哲大三追过我" | 5 方向 + 追问 Round 3 |
-| 10 | 妈妈 | 文字 | "睡了吗?" | 5 方向 + 温暖/追问各有 Round 3 |
+| 10 | 妈妈 | 文字(系统语音朗读) | "睡了吗?" | 5 方向 + 温暖/追问各有 Round 3 |
| 11 | 阿哲 | 电话 | "我能过去拿吗?就 5 分钟" | 5 方向(关键分支锚点) |
| 12 | 周南 | 语音 | "初三坐你后桌那个" | 5 方向 |
| 13 | 周南 | 语音 ×4 | "今天我生日" | 5 方向 + 温暖 Round 3 |
@@ -269,6 +275,8 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
**无选择**——悬疑钩子。
**不接** → 未接来电。
+**回收**:MSG-16 妈妈长语音第 5 段中提到"我外婆每天晚上都给我打一个电话,她不说什么"——暗示未知号码就是妈妈从另一个号码拨出的"不说什么的电话",与妈妈线形成跨代呼应。不做明确揭示,留给玩家自己领悟。如果玩家接了 MSG-08 + 听完 MSG-16,两段呼吸/沉默会在记忆中形成对位。
+
---
### MSG-09 小美(醉)· 语音 ×4
@@ -298,15 +306,15 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
### MSG-10 妈妈 · 文字消息
-**Round 1**:消息到达提示音。TTS 朗读三个字——"睡了吗?"
+**Round 1**:消息到达提示音。系统语音朗读三个字——"睡了吗?"
| 方向 | 林夏说 | 妈妈反应 | 效果 |
|------|--------|---------|------|
| 右滑 | "没有,怎么了?" | 妈妈发语音:"没事,就是想起来问一下。今天怎么样?" | MOM_LINK, engagement+2 |
-| 左滑 | "嗯,要睡了。" | 妈妈发文字:"好,早点睡。" | MOM_LINK, avoidance+1 |
+| 左滑 | "嗯,要睡了。" | 系统语音朗读妈妈的文字:"好,早点睡。" | MOM_LINK, avoidance+1 |
| 上滑 | "妈……你能打电话给我吗?" | [电话铃响] 妈妈打来了!接听后:"怎么了夏夏?" | MOM_LINK, engagement+3 |
-| 下滑 | [不回复] | [30 秒后] 妈妈发文字:"看到了回一下。" | avoidance+2 |
-| 单击 | "嗯。" | 妈妈发文字:"嗯就是睡了?晚安。" | MOM_LINK |
+| 下滑 | [不回复] | [30 秒后] 系统语音朗读妈妈的文字:"看到了回一下。" | avoidance+2 |
+| 单击 | "嗯。" | 系统语音朗读妈妈的文字:"嗯就是睡了?晚安。" | MOM_LINK |
**上滑路径**(妈妈打来电话后,说"怎么了夏夏?你声音不对。"):
- 右滑 → 林夏:"没什么……就是有点累。" → 妈妈:"累了就回家歇几天。妈煮了排骨。"
@@ -315,7 +323,9 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
**右滑路径**(妈妈问"今天怎么样"后):
- 右滑 → 林夏:"还好。" → 妈妈:"那就好。别太晚了。"
-- 上滑 → 林夏:"不太好。" → 妈妈:"怎么了?" → [可继续展开]
+- 上滑 → 林夏:"不太好。" → 妈妈:"怎么了?"
+ - 右滑 → 林夏:"工作没了。" → 妈妈:"……回来歇几天吧。妈不催你。"
+ - 下滑 → [沉默] → 妈妈:"不想说就不说。妈在这呢。" → 对话结束
---
@@ -328,10 +338,15 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|------|--------|---------|------|
| 右滑 | "可以。但只有 5 分钟。" | "好,我马上过来。谢谢。" → [挂断] | HE_BACK=true |
| 左滑 | "不方便。我明天寄给你。" | "好吧……那谢了。" → [挂断] | HE_BACK=false |
-| 上滑 | "为什么非要今天来?" | "我……明天要出差。" [停顿] "就 5 分钟。" | 继续选择 |
+| 上滑 | "为什么非要今天来?" | "我……明天要出差。" [停顿] "就 5 分钟。" | → 追问后再选 |
| 下滑 | [沉默 → 挂断音] | [电话断了] | HE_BACK=false |
| 单击 | "不了。" | "好。" → [挂断] | HE_BACK=false |
+**上滑 Round 3**(阿哲说"就 5 分钟"后,再次进入选择):
+- 右滑 → 林夏:"……来吧。" → 阿哲:"谢谢。我马上到。" → [挂断] → HE_BACK=true
+- 左滑 → 林夏:"还是算了。" → 阿哲:"好吧。" → [挂断] → HE_BACK=false
+- 下滑 → [沉默] → 阿哲:"那……你考虑一下,我等你消息。" → [挂断] → HE_BACK=false
+
**如果 HE_BACK=true** → 消息 12 之前插入门铃声 + 敲门声场景。
---
@@ -382,8 +397,12 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
接听后 → 林夏自动说"嗯,我没事。"
妈妈:"真的没事?"
-- 如果 MOM_LINK=true:"好。那早点睡。妈爱你。"
-- 如果 MOM_LINK=false:"你今天怎么都不理妈妈?出什么事了吗?" → 可选择回应
+- 如果 MOM_LINK=true:"好。那早点睡。妈爱你。" → 对话结束
+- 如果 MOM_LINK=false:"你今天怎么都不理妈妈?出什么事了吗?" → 进入选择:
+ - 右滑 → 林夏:"没事妈,就是手机坏了。" → 妈妈:"那你赶紧修啊。好了早点睡。" → MOM_LINK=true
+ - 左滑 → 林夏:"嗯,没事。" → 妈妈:"……好吧。晚安。" → MOM_LINK=true
+ - 下滑 → [沉默] → 妈妈:"你不说话我更担心。算了,不逼你了。晚安。" → MOM_LINK=true
+**不接** → MOM_LINK 保持当前值(可能仍为 false)
**状态**:接听 → MOM_LINK=true
@@ -409,12 +428,16 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
3. "那时候妈妈在广州,给一个香港老板做秘书,1995 年。那年我也被分手过。也被开除过。"
4. "那年我哭得特别难看。后来呢?后来就没什么后来了。我回了老家,认识了你爸,生了你。"
5. "我想跟你说,那年夏天我没死,是因为我外婆每天晚上都给我打一个电话。"
+ - **音频设计**:第 5 段开头,叠入与 MSG-08 相同的电话底噪质感(轻微线路嘶声),持续 2-3 秒后淡出。如果玩家之前接了 MSG-08 的未知号码,这一刻会在听觉上产生似曾相识的感觉——同样的电话底噪、同样的呼吸停顿,但这次有了妈妈的声音。
6. "她不说什么,就问我今天吃饭了没。"
+ - **音频设计**:"不说什么"之后留 3 秒静默(仅电话底噪),呼应 MSG-08 的 6 秒呼吸声,形成跨消息的听觉对位。
7. "我现在打给你,你不接也没事。妈妈给你发个语音,你什么时候想听就听。你不用回我。"
8. "你今天接了好多电话吧。妈妈不打扰你了。晚安。"
*无选择交互。让玩家安静听完。播放结束 → 触发结局判定。*
+**如果 MOM_LINK=false**:MSG-16 不播放。MSG-15 结束后,静默 5 秒(环境底噪),然后直接进入结局判定。此时结局 A 的条件不可能满足(缺少 MOM_LINK),流程会落入结局 B/C/D。
+
---
## 5. 分支判定变量
@@ -424,10 +447,35 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
| **MOM_LINK** | 是否接妈妈两次电话中的至少一次 / 是否回复"睡了吗?" | 决定 MSG-16 是否播放 |
| **HE_BACK** | 是否答应阿哲过来拿耳机(MSG-11 右滑) | 决定 MSG-12 前是否插入门铃场景 |
| **GROUP_REPLY** | 是否在寝室群里回复过(MSG-07 非沉默) | 决定彩蛋消息 |
-| **ZHOUNAN_DEPTH** | 给周南的回应深度(累计) | 决定结局字幕 + self-3y 版本 |
+| **ZHOUNAN_DEPTH** | 给周南的回应深度(累计) | 决定结局独白 + self-3y 版本 |
| **ZHOUNAN_SHARE** | 是否在周南对话中选择"分享自己"(MSG-13 上滑/右滑 R3 右滑) | 决定隐藏结局 |
| **SELF_RECORD** | 是否听完 3 年前备忘录(MSG-15) | 决定结局类型 |
| **PROFILE** | 全场行为聚合画像:avoidance / engagement / nostalgia | 决定 self-3y 版本 + 环境音 |
+| **UNREAD_COUNT** | 未播放/未接听的消息数(初始 16,每播放或接听 −1) | 决定结局 C/D |
+| **REPLY_COUNT** | 林夏实际发出回应的消息条数 | 决定结局 C |
+
+**REPLY_COUNT 计数规则**:
+- 选择右滑/左滑/上滑/单击(发出语音回应)→ +1
+- 下滑(沉默)→ 不计
+- 电话接听后自动回应(MSG-01/03/14)→ +1
+- 双击跳过整条消息 → 不计
+- 发表情(MSG-07 单击)→ +1
+
+### 变量影响层级
+
+并非所有选择都对结局产生等量影响。设计上区分**核心变量**与**氛围变量**:
+
+| 层级 | 变量 | 影响 | 相关消息 |
+|------|------|------|---------|
+| **核心** | MOM_LINK | 决定 MSG-16 是否播放(妈妈长独白),直接影响结局 A | MSG-01, 10, 14 |
+| **核心** | HE_BACK | 决定门铃场景是否触发,区分结局 A/B | MSG-11 |
+| **核心** | ZHOUNAN_DEPTH + ZHOUNAN_SHARE | 决定隐藏结局 E | MSG-12, 13 |
+| **辅助** | SELF_RECORD | 决定结局 A 是否可达 | MSG-15 |
+| **氛围** | GROUP_REPLY | 影响彩蛋消息,但不改变结局走向 | MSG-07 |
+| **氛围** | PROFILE(avoidance/engagement/nostalgia) | 影响环境音色调 + self-3y 版本,不直接决定结局 | 全局累计 |
+| **统计** | UNREAD_COUNT / REPLY_COUNT | 决定结局 C/D 的触发条件 | 全局自动累计 |
+
+> **设计原则**:HR、安安、寝室群、小美醉酒等消息的选择主要影响 PROFILE 氛围和当下的对话反馈,让玩家"听得出这一句不一样"。真正改变结局走向的是**妈妈线、阿哲线、周南线**三条核心关系。玩家在这三条线上的选择会产生明确的、跨消息的后果。
### PROFILE 计分细则
@@ -450,31 +498,48 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
### 结局 A · "明天就明天吧"
**触发**:MOM_LINK=true + SELF_RECORD=true + 至少回了周南或群里 1 条
+**优先级**:2
- 妈妈语音播完。林夏起身,环境声:走到厨房、烧水、坐在小板凳上。
-- 字幕:"明天再说。"
+- 林夏轻声自语:"明天再说。"
- 烧水壶滴的一声 → 游戏结束。
### 结局 B · "AirPods 我寄给你"
**触发**:HE_BACK=false + 回过周南至少 1 条
-- 林夏给阿哲发了文字"我寄给你,地址发我"。
-- 字幕:"寄出。"
+**优先级**:3
+- 林夏给阿哲发语音"我寄给你,地址发我"(打字音效 → 发送音效 → 林夏语音播放)。
+- 林夏轻声自语:"寄出去了。"
- 一个"清醒地走"的结局。
### 结局 C · "全部已读"
-**触发**:未读数=0 且回复≤2 条
-- TTS:"今晚共 16 条新消息,已读 16,未回复 14。"
-- 长沉默。字幕:"今晚就这样。"
+**触发**:UNREAD_COUNT=0 且 REPLY_COUNT≤2
+**优先级**:4
+- 系统语音播报实际数值:"今晚共 16 条新消息,已读 {16−UNREAD_COUNT},未回复 {16−REPLY_COUNT}。"
+- 长沉默。林夏轻声自语:"今晚就这样。"
### 结局 D · "明天再开机"
-**触发**:未读数≥8
-- 系统提示电量 1%。触感震动 + 白噪 → 彻底黑。
-- 第二天清晨鸟叫。字幕:"早上好。"
+**触发**:UNREAD_COUNT≥8,**或**结局 E/A/B/C 均不满足(真正兜底)
+**优先级**:5(兜底结局)
+- 系统语音提示电量 1%。触感震动 + 白噪 → 彻底黑。
+- 第二天清晨鸟叫。林夏轻声自语:"早上好。"
### 结局 E · 隐藏 · "周南,我也记得你"
**触发**:ZHOUNAN_DEPTH≥2 + ZHOUNAN_SHARE + PROFILE=engagement 或 nostalgia
+**优先级**:1(最高优先级)
- 周南秒回:"那我下次出差来北京,请你吃饭好不好。"
- 系统播放林夏的轻笑——这是全游戏唯一一次林夏主动笑出声。
-- 字幕:"好。"
+- 林夏轻声说:"好。"
+
+### 结局判定规则
+
+当玩家同时满足多个结局条件时,按**优先级数字从小到大**判定,取第一个满足的结局:
+
+```
+E(隐藏,优先级 1)→ A(优先级 2)→ B(优先级 3)→ C(优先级 4)→ D(兜底,优先级 5)
+```
+
+- 结局 E 最稀有、最难触发,一旦满足则覆盖其他结局。
+- 结局 D 是**真正的兜底结局**——无论玩家怎么玩,只要 E/A/B/C 都不满足就一定进入 D。UNREAD_COUNT≥8 是 D 的常见触发场景,但不是唯一入口。
+- 结局 A 和 B 可能同时满足(例如 MOM_LINK=true + SELF_RECORD=true + HE_BACK=false + 回过周南),此时优先触发 A,因为 A 是更完整的情感闭合。
---
@@ -493,7 +558,9 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
| **环境音** | 卧室夜噪、酒吧、街声 | 4-5 段 | Freesound (CC0/CC-BY) |
| **角色铃声** | 来电铃声 | 3-4 段 | 简单合成或 Suno 短旋律 |
-### 7.2 TTS 方案:VoxCPM2 本地声音克隆
+### 7.2 TTS 方案:VoxCPM2 本地声音克隆(唯一主链路)
+
+> **注意**:本项目 TTS 统一使用 **VoxCPM2**。`执行文档_音频素材制作.md` 中早期记录的 CosyVoice2 方案作为备用,不作为主生产链路。批量脚本、音色目录、QA 标准均以 VoxCPM2 为准。
所有角色语音使用 **VoxCPM2**(本地部署,无需外部 API),通过声音克隆实现一致的角色音色。
@@ -569,6 +636,7 @@ python audio/batch_tts_voxcpm.py --source voice --dry-run
| `choiceSilent` | 下滑沉默 | 330Hz 极轻 |
| `typing` | 打字中 | 6 次随机频率短脉冲 |
| `send` | 发送 | 400→900Hz 上扫 |
+| `longPress` | 长按确认 | 330→440Hz 缓升 |
| `ending` | 结局 | 三音渐弱 |
### 7.6 BGM 与环境音
@@ -592,9 +660,9 @@ python audio/batch_tts_voxcpm.py --source voice --dry-run
### 8.1 技术栈
纯前端 Web 应用(单 HTML + JS),无需后端服务器:
-- **手势识别**:`gestures.js` — 6 种手势(单击、双击、四方向滑动)
+- **手势识别**:`gestures.js` — 7 种手势(单击、双击、长按、四方向滑动)
- **音频管理**:`audio.js` — Web Audio API 合成 + HTML5 Audio 播放
-- **触觉反馈**:`haptics.js` — Vibration API
+- **触觉反馈**:`haptics.js` — Vibration API(增强反馈,非必需;见下方降级说明)
- **剧情数据**:`story.js` — 16 条消息的完整对话树
- **游戏引擎**:`engine.js` — 状态机 + 异步对话流程
- **行为画像**:`profile.js` — PROFILE 计分 + 状态变量
@@ -605,20 +673,43 @@ python audio/batch_tts_voxcpm.py --source voice --dry-run
idle → notification/ringing → playing → choosing → responding → reacting → [choosing | idle] → ending
```
-| 状态 | 说明 | 可用手势 |
-|------|------|---------|
-| idle | 等待消息 | — |
-| notification | 消息到达提示中 | 单击(播放)、双击(跳过) |
-| ringing | 来电铃声中 | 单击(接听)、左滑(拒接) |
-| playing | NPC 语音播放中 | 双击(跳过语音) |
-| choosing | 等待玩家选择 | 右滑、左滑、上滑、下滑、单击 |
-| responding | 林夏语音播放中 | 双击(跳过) |
-| reacting | NPC 反应播放中 | 双击(跳过) |
-| ending | 结局播放中 | — |
+| 状态 | 说明 | 可用手势 | 进入音效 |
+|------|------|---------|---------|
+| idle | 等待消息 | 长按(播报状态) | — |
+| notification | 消息到达提示中 | 单击(播放)、双击(跳过)、长按(播报状态) | 提示音 + 震动 |
+| ringing | 来电铃声中 | 单击(接听)、左滑(拒接)、长按(播报来电人) | 铃声 + 长震 |
+| playing | NPC 语音播放中 | 双击(跳过语音)、长按(重听本句) | — |
+| choosing | 等待玩家选择 | 右滑、左滑、上滑、下滑、单击、长按(重听 NPC 上一句) | **三音上行 + 长-短-长震动 + "等待回复"语音** |
+| responding | 林夏语音播放中 | 双击(跳过) | 打字音效 |
+| reacting | NPC 反应播放中 | 双击(跳过) | — |
+| ending | 结局播放中 | 三指点击(退出) | 结局专属音效 |
+
+> **关键**:`playing → choosing` 的状态切换必须有**明确的听觉分界**(三音上行 + 震动模式变化 + "等待回复"语音提示),确保玩家在纯音频环境下不会误以为自己还在听消息。单击在 `notification` 状态是"播放",在 `choosing` 状态是"嗯"——两者之间有明确的进入选择态提示,不会混淆。
### 8.3 异步对话流程
```javascript
+// 进入选择态时的明确确认
+function enterChoosing(choices) {
+ state = 'choosing';
+ SFX_UI.choiceReady(); // 三音上行
+ haptics.pattern([200, 100, 80, 100, 200]); // 长-短-长 震动
+ speak('等待回复'); // 系统语音确认
+ currentChoices = choices;
+}
+
+// 长按处理:任何状态下可用
+async _handleLongPress() {
+ SFX_UI.longPress();
+ if (state === 'choosing' || state === 'playing') {
+ // 重播上一句 NPC 语音
+ await playVoiceAsync([lastNpcAudio]);
+ } else {
+ // 播报当前状态
+ speak(getStatusText()); // 如:"小美的消息,等待回复"
+ }
+}
+
async _handleChoice(dir) {
const choice = currentMsg.choices[dir];
@@ -667,7 +758,29 @@ async _handleChoice(dir) {
- 中断当前语音播放
- 异步函数在每个步骤检查 skip 标志,快速跳到下一阶段
-### 8.5 隐私承诺
+### 8.5 全局安全手势
+
+除 §2.1 的 7 种剧情手势外,有两种**全局安全手势**,它们的优先级高于任何剧情手势,在所有状态下均可触发:
+
+| 手势 | 功能 | 优先级 |
+|------|------|--------|
+| **两指长按(1s)** | 暂停游戏 | 最高——立即中断当前音频播放,进入暂停状态 |
+| **三指点击** | 安全退出 | 最高——系统语音确认后跳转到支持资源页 |
+
+**实现要求**:
+- `gestures.js` 在手势识别时先判断触摸点数量:≥3 → 安全退出,=2 且持续 ≥1s → 暂停,=1 → 进入剧情手势判断。
+- 暂停状态下仅响应:单击(继续)、三指点击(退出)。
+- 安全手势不影响任何游戏变量(PROFILE、REPLY_COUNT 等不变)。
+
+### 8.6 触觉降级策略
+
+触觉反馈(Vibration API)是增强体验,**不是必需通道**。iPhone Safari 不支持 Vibration API,部分 Android 浏览器也可能受限。设计原则:
+
+- **所有状态切换必须有独立的音效/语音确认**,不能仅靠震动区分。
+- `haptics.js` 在初始化时检测 `navigator.vibrate` 是否可用,不可用时静默降级。
+- 测试时必须在关闭震动的情况下完整通关一遍,确认纯音频也能正常游玩。
+
+### 8.6 隐私承诺
本作**不申请任何敏感权限**:
- ❌ 不要麦克风、摄像头、位置、通讯录
@@ -707,7 +820,7 @@ async _handleChoice(dir) {
- 林夏 ~55 条,~100 秒
- NPC ~90 条,~835 秒
- **总语音约 145 条,约 15.5 分钟**
-- UI 音效:12 种,全部 Web Audio 合成
+- UI 音效:13 种,全部 Web Audio 合成
- BGM:4 段
- 环境音:4-5 段
@@ -721,7 +834,7 @@ blind/
│ ├── index.html # 入口
│ └── js/
│ ├── main.js # 启动 + 事件绑定
-│ ├── gestures.js # 6 种手势识别
+│ ├── gestures.js # 7 种手势识别(含长按)
│ ├── haptics.js # 触觉反馈
│ ├── audio.js # 音频管理 + UI 合成音效
│ ├── story.js # 16 条消息剧情数据
@@ -763,7 +876,27 @@ blind/
| 手势误判导致选错 | 滑动阈值 30px + 40°范围;双击间隔 250ms 内 |
| 玩家不理解手势含义 | MSG-02(小美教学关)强引导,每步正向反馈 |
| 女性玩家代入感 | 编剧组需有 24 岁左右女性参与 |
-| 题材敏感 | 开场加心理预警 + 提供试玩版 |
+| 题材敏感 | 见下方"情绪安全设计"专节 |
+
+### 情绪安全设计
+
+本作涉及分手、裁员、独居、深夜来电、家庭创伤回忆等高情绪浓度内容,必须认真对待玩家的心理安全。
+
+**开场内容提示**(游戏启动后、第一条消息前):
+- 系统语音播报:"本游戏涉及情感压力、职场失落、家庭关系等主题。如果你当前情绪状态不佳,建议择日再玩。游戏过程中,两指长按可暂停,三指点击可直接退出。"
+
+**游戏中安全机制**:
+- **随时暂停**:任何状态下两指同时长按 1 秒,游戏暂停,系统语音:"游戏已暂停。单击继续,三指点击退出。"(两指长按与单指双击完全不同,避免误触跳过)
+- **安全退出**:三指同时点击,系统语音确认后直接跳转到结尾支持资源页。
+- **长按状态播报**:让玩家随时确认自己在哪,减少焦虑感。
+
+**结尾支持资源**(任何结局播放完毕后):
+- 系统语音:"感谢你陪林夏度过这一夜。如果你现在需要和人说说话:全国 24 小时心理援助热线 400-161-9995;北京心理危机研究与干预中心 010-8295-1332。"
+- 音频播放完毕后保持在屏幕上(即使是纯音频游戏,此处允许显示文字——用户此时可以看手机了,屏幕"修好了"是一个隐喻)。
+
+**叙事安全原则**:
+- 林夏的自救不完全寄托在妈妈或男性角色身上——妈妈提供的是"被记住"的感觉,周南提供的是"被看见"的感觉,但最终让林夏起身烧水、给阿哲寄耳机的,是她自己的选择。
+- MSG-16 妈妈语音中"那年夏天我没死"这句表述需谨慎处理:妈妈的语气是平静追忆而非戏剧化,TTS 合成时 emotion 设为 `gentle, peaceful`,避免制造突然的情绪冲击。
---