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`,避免制造突然的情绪冲击。 ---