/**
* 实时语音通话功能
* 真正的语音交互:用户说话 → STT → AI → TTS → 播放
*/
import { getSettings } from './config.js';
import { currentChatIndex } from './chat.js';
import { requestSave } from './save-manager.js';
import { refreshChatList } from './ui.js';
import { AudioRecorder, speechToText, textToSpeech, playAudio } from './voice-api.js';
import { showToast } from './toast.js';
import { saveVoiceRecordings } from './audio-storage.js';
// 通话状态
let callState = {
isActive: false,
isConnected: false,
isMuted: false,
isHangingUp: false, // 是否正在挂断
startTime: null,
timerInterval: null,
dotsInterval: null,
connectTimeout: null,
contactIndex: -1,
contactName: '',
contactAvatar: '',
messages: [], // 通话消息记录
contact: null,
initiator: 'user',
rejectedByUser: false,
rejectedByAI: false,
isRecording: false, // 是否正在录音
isProcessing: false, // 是否正在处理(STT/AI/TTS)
isPlaying: false, // 是否正在播放语音
recorder: null, // 录音器实例
currentAudio: null, // 当前播放的音频
voiceCache: [] // 缓存的 AI 语音 [{text, audioBlob, duration}]
};
/**
* 开始实时语音通话
*/
export function startRealVoiceCall(initiator = 'user', contactIndex = currentChatIndex) {
if (callState.isActive) return;
if (contactIndex < 0) return;
const settings = getSettings();
const contact = settings.contacts[contactIndex];
if (!contact) return;
// 检查语音 API 是否配置
if (!settings.sttApiUrl || !settings.sttApiKey) {
alert('请先在设置中配置语音识别 (STT) API');
return;
}
if (!settings.ttsApiUrl || !settings.ttsApiKey) {
alert('请先在设置中配置语音合成 (TTS) API');
return;
}
// 检查浏览器是否支持录音(不阻止进入,可以用文字输入)
const supportsRecording = AudioRecorder.isSupported();
if (!supportsRecording) {
console.log('[可乐] 浏览器不支持录音,将使用文字输入模式');
}
callState.contactName = contact.name;
callState.contactAvatar = contact.avatar;
callState.contact = contact;
callState.contactIndex = contactIndex;
callState.isActive = true;
callState.isConnected = false;
callState.isMuted = false;
callState.messages = [];
callState.initiator = initiator;
callState.rejectedByUser = false;
callState.rejectedByAI = false;
callState.isRecording = false;
callState.isProcessing = false;
callState.isPlaying = false;
callState.recorder = new AudioRecorder();
callState.voiceCache = []; // 重置语音缓存
callState.isHangingUp = false; // 重置挂断标志
showCallPage();
startConnecting();
}
/**
* 显示通话页面
*/
function showCallPage() {
const page = document.getElementById('wechat-real-voice-call-page');
if (!page) return;
// 设置头像
const avatarEl = document.getElementById('wechat-real-voice-call-avatar');
if (avatarEl) {
const firstChar = callState.contactName ? callState.contactName.charAt(0) : '?';
if (callState.contactAvatar) {
avatarEl.innerHTML = ``;
} else {
avatarEl.textContent = firstChar;
}
}
// 设置名称
const nameEl = document.getElementById('wechat-real-voice-call-name');
if (nameEl) {
nameEl.textContent = callState.contactName;
}
// 设置状态
const statusEl = document.getElementById('wechat-real-voice-call-status');
if (statusEl) {
if (callState.initiator === 'ai') {
statusEl.textContent = '邀请你实时语音...';
} else {
statusEl.textContent = '等待对方接受邀请';
}
statusEl.classList.add('connecting');
}
// 重置时间显示
const timeEl = document.getElementById('wechat-real-voice-call-time');
if (timeEl) {
timeEl.textContent = '00:00';
timeEl.classList.add('hidden');
}
// 隐藏对话区域
const chatEl = document.getElementById('wechat-real-voice-call-chat');
if (chatEl) {
chatEl.classList.add('hidden');
}
const messagesEl = document.getElementById('wechat-real-voice-call-messages');
if (messagesEl) {
messagesEl.innerHTML = '';
}
// 隐藏按住说话按钮
const talkBtnArea = document.getElementById('wechat-real-voice-call-talk-area');
if (talkBtnArea) {
talkBtnArea.classList.add('hidden');
}
// 检测是否支持录音
const supportsRecording = AudioRecorder.isSupported();
const talkBtn = document.getElementById('wechat-real-voice-call-talk-btn');
const talkHint = document.querySelector('.wechat-real-voice-call-talk-hint');
const textInputArea = document.getElementById('wechat-real-voice-call-text-input-area');
// 语音按钮:只有支持录音时显示
if (talkBtn) talkBtn.style.display = supportsRecording ? 'flex' : 'none';
if (talkHint) {
if (supportsRecording) {
talkHint.style.display = 'block';
talkHint.textContent = '点击开始说话,再次点击发送';
} else {
// 显示不支持的原因
talkHint.style.display = 'block';
talkHint.textContent = '💡 ' + AudioRecorder.getUnsupportedReason();
talkHint.style.color = '#ff9800';
}
}
// 文字输入:始终显示,方便用户选择打字或语音
if (textInputArea) textInputArea.style.display = 'flex';
// 根据发起者显示不同的操作按钮
const incomingActionsEl = document.getElementById('wechat-real-voice-call-incoming-actions');
const callActionsEl = document.getElementById('wechat-real-voice-call-actions');
if (callState.initiator === 'ai') {
if (incomingActionsEl) incomingActionsEl.classList.remove('hidden');
if (callActionsEl) callActionsEl.classList.add('hidden');
} else {
if (incomingActionsEl) incomingActionsEl.classList.add('hidden');
if (callActionsEl) callActionsEl.classList.remove('hidden');
}
page.classList.remove('hidden');
bindCallEvents();
}
/**
* 开始连接动画
*/
async function startConnecting() {
const statusEl = document.getElementById('wechat-real-voice-call-status');
if (!statusEl) return;
let dotCount = 0;
clearInterval(callState.dotsInterval);
clearTimeout(callState.connectTimeout);
const waitingText = callState.initiator === 'ai' ? '邀请你实时语音' : '等待对方接受邀请';
callState.dotsInterval = setInterval(() => {
dotCount = (dotCount + 1) % 4;
const dots = '.'.repeat(dotCount);
statusEl.textContent = waitingText + dots;
}, 500);
if (callState.initiator === 'user') {
// 用户发起:调用AI决策是否接听
const shouldAnswer = await askAIToAnswerCall(callState.contact);
if (!callState.isActive) return;
if (shouldAnswer) {
if (callState.isActive && !callState.isConnected) {
onCallConnected();
}
} else {
callState.rejectedByAI = true;
hangupCall();
}
} else {
// AI发起:15秒后超时
callState.connectTimeout = setTimeout(() => {
if (callState.isActive && !callState.isConnected) {
callState.rejectedByUser = false;
hangupCall();
}
}, 15000);
}
}
/**
* AI决定是否接听
*/
async function askAIToAnswerCall(contact) {
if (!contact) return true;
try {
const { callAI } = await import('./ai.js');
const prompt = `[用户正在给你打实时语音电话,你需要决定是否接听]
根据你的性格和当前心情决定:
- 如果你想接听,只回复:[接听]
- 如果你不想接听,只回复:[拒接]
注意:大多数情况下你应该接听,只有特殊情况才拒接。`;
const response = await callAI(contact, prompt);
const trimmed = (response || '').trim();
console.log('[可乐] 实时语音 AI接听决策:', trimmed);
if (trimmed.includes('[拒接]') || trimmed.includes('拒接')) {
return false;
}
return true;
} catch (err) {
console.error('[可乐] AI接听决策失败:', err);
return true;
}
}
/**
* 通话接通
*/
function onCallConnected() {
callState.isConnected = true;
callState.startTime = Date.now();
clearInterval(callState.dotsInterval);
clearTimeout(callState.connectTimeout);
const statusEl = document.getElementById('wechat-real-voice-call-status');
if (statusEl) {
statusEl.textContent = '通话中';
statusEl.classList.remove('connecting');
}
// 显示计时器
const timeEl = document.getElementById('wechat-real-voice-call-time');
if (timeEl) {
timeEl.classList.remove('hidden');
}
// 显示对话区域
const chatEl = document.getElementById('wechat-real-voice-call-chat');
if (chatEl) {
chatEl.classList.remove('hidden');
}
// 显示按住说话按钮
const talkBtnArea = document.getElementById('wechat-real-voice-call-talk-area');
if (talkBtnArea) {
talkBtnArea.classList.remove('hidden');
}
// 切换到通话中按钮
const incomingActionsEl = document.getElementById('wechat-real-voice-call-incoming-actions');
const callActionsEl = document.getElementById('wechat-real-voice-call-actions');
if (incomingActionsEl) incomingActionsEl.classList.add('hidden');
if (callActionsEl) callActionsEl.classList.remove('hidden');
startCallTimer();
// AI发起的通话,接通后AI先打招呼
if (callState.initiator === 'ai') {
triggerAIGreeting();
}
}
/**
* 开始通话计时
*/
function startCallTimer() {
clearInterval(callState.timerInterval);
callState.timerInterval = setInterval(() => {
if (!callState.isConnected || !callState.startTime) return;
const elapsed = Math.floor((Date.now() - callState.startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
const timeEl = document.getElementById('wechat-real-voice-call-time');
if (timeEl) {
timeEl.textContent = `${minutes}:${seconds}`;
}
}, 1000);
}
/**
* AI主动打招呼(AI发起通话时)
*/
async function triggerAIGreeting() {
if (!callState.isConnected || !callState.contact) return;
updateStatus('AI思考中...');
try {
const { callRealVoiceAI } = await import('./ai.js');
const aiResponse = await callRealVoiceAI(
callState.contact,
'[用户接听了实时语音电话]',
[],
'ai'
);
// 清理回复
let reply = cleanAIReply(aiResponse);
if (!reply) return;
// 添加消息记录
addCallMessage('ai', reply);
// TTS 合成并播放
await speakText(reply);
updateStatus('通话中');
} catch (err) {
console.error('[可乐] AI打招呼失败:', err);
updateStatus('通话中');
}
}
/**
* 清理 AI 回复(移除特殊标签,保留完整内容)
*/
function cleanAIReply(text) {
if (!text) return '';
console.log('[可乐] AI原始回复:', text);
let reply = text.trim();
// 移除语音标记
const voiceMatch = reply.match(/^\[语音[::]\s*(.+?)\]$/);
if (voiceMatch) {
reply = voiceMatch[1];
}
// 移除特殊标记
reply = reply.replace(/\[.*?\]/g, '').trim();
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
// 移除括号描述(中文和英文括号)
reply = reply.replace(/([^)]+)/g, '').trim();
reply = reply.replace(/\([^)]+\)/g, '').trim();
// 如果清理后为空,用原始内容去掉标记
if (!reply && text.trim()) {
reply = text.trim().replace(/[\[\]()()【】<>]/g, '').trim();
console.log('[可乐] 清理后为空,恢复内容:', reply);
}
console.log('[可乐] 最终回复:', reply || '(空)');
return reply;
}
/**
* TTS 合成并播放
*/
async function speakText(text) {
if (!text || callState.isPlaying) return;
callState.isPlaying = true;
updateStatus('语音合成中...');
try {
console.log('[可乐] 开始TTS合成:', text.substring(0, 50));
const audioBlob = await textToSpeech(text, callState.contact);
// 检查音频数据
console.log('[可乐] TTS返回音频:', {
size: audioBlob?.size,
type: audioBlob?.type
});
if (!audioBlob || audioBlob.size < 100) {
console.error('[可乐] TTS返回的音频数据无效');
updateStatus('语音合成失败');
return;
}
updateStatus('对方正在说话...');
// 播放音频
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// 设置音量
audio.volume = 1.0;
let audioDuration = 0;
await new Promise((resolve, reject) => {
audio.onended = () => {
URL.revokeObjectURL(audioUrl);
resolve();
};
audio.onerror = (e) => {
URL.revokeObjectURL(audioUrl);
console.error('[可乐] 音频播放错误:', e);
reject(new Error('音频播放失败'));
};
audio.oncanplaythrough = () => {
audioDuration = audio.duration;
console.log('[可乐] 音频可以播放,时长:', audioDuration);
};
audio.play().then(() => {
console.log('[可乐] 音频开始播放');
}).catch(err => {
console.error('[可乐] 音频播放被阻止:', err);
reject(err);
});
});
// 播放成功后缓存音频(用于通话结束后选择保存)
callState.voiceCache.push({
text: text,
audioBlob: audioBlob,
duration: audioDuration || (audioBlob.size / 16000) // 估算时长
});
console.log('[可乐] 语音已缓存,当前缓存数量:', callState.voiceCache.length);
} catch (err) {
console.error('[可乐] TTS 播放失败:', err);
// 显示错误提示
const errorMsg = err.message || '语音播放失败';
updateStatus('语音失败');
showToast('语音合成失败: ' + errorMsg.substring(0, 30), '⚠️');
await new Promise(r => setTimeout(r, 1500));
} finally {
callState.isPlaying = false;
if (callState.isConnected) {
updateStatus('通话中');
}
}
}
/**
* 开始录音(按住说话)
*/
async function startRecording() {
if (!callState.isConnected || callState.isRecording || callState.isProcessing || callState.isPlaying) {
return;
}
try {
await callState.recorder.start();
callState.isRecording = true;
updateTalkButton(true);
updateStatus('正在录音...');
} catch (err) {
console.error('[可乐] 开始录音失败:', err);
alert(err.message);
}
}
/**
* 停止录音并处理
*/
async function stopRecording() {
if (!callState.isRecording) return;
callState.isRecording = false;
updateTalkButton(false);
try {
const audioBlob = await callState.recorder.stop();
if (audioBlob.size < 1000) {
console.log('[可乐] 录音太短,忽略');
updateStatus('通话中');
return;
}
callState.isProcessing = true;
updateStatus('识别中...');
// STT 语音转文字
const userText = await speechToText(audioBlob);
if (!userText || !userText.trim()) {
console.log('[可乐] 未识别到语音');
showToast('未识别到语音内容', 'info');
updateStatus('通话中');
callState.isProcessing = false;
return;
}
console.log('[可乐] 用户说:', userText);
// 添加用户消息
addCallMessage('user', userText);
// 调用 AI(带超时保护,使用实时语音专用函数)
updateStatus('AI思考中...');
const { callRealVoiceAI } = await import('./ai.js');
// 30秒超时
const aiPromise = callRealVoiceAI(
callState.contact,
userText,
callState.messages.slice(0, -1),
callState.initiator
);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('AI响应超时')), 30000)
);
const aiResponse = await Promise.race([aiPromise, timeoutPromise]);
// 清理回复
let reply = cleanAIReply(aiResponse);
callState.isProcessing = false;
if (!reply) {
updateStatus('通话中');
return;
}
// 添加 AI 消息
addCallMessage('ai', reply);
// TTS 并播放
await speakText(reply);
// 检查是否要挂断
if (detectHangupIntent(reply)) {
setTimeout(() => {
if (callState.isConnected) {
hangupCall();
}
}, 1500);
}
} catch (err) {
console.error('[可乐] 语音处理失败:', err);
callState.isProcessing = false;
updateStatus('通话中');
// 显示具体错误
const errorMsg = err.message || '处理失败';
showToast('语音处理失败: ' + errorMsg.substring(0, 30), '⚠️');
}
}
/**
* 取消录音
*/
function cancelRecording() {
if (callState.recorder) {
callState.recorder.cancel();
}
callState.isRecording = false;
updateTalkButton(false);
updateStatus('通话中');
}
/**
* 处理文字输入(不支持录音时的替代方案)
*/
async function processUserTextInput(userText) {
if (!callState.isConnected || callState.isProcessing || callState.isPlaying) {
return;
}
try {
console.log('[可乐] 用户输入:', userText);
// 添加用户消息
addCallMessage('user', userText);
callState.isProcessing = true;
// 调用 AI(带超时保护)
updateStatus('AI思考中...');
const { callRealVoiceAI } = await import('./ai.js');
// 30秒超时
const aiPromise = callRealVoiceAI(
callState.contact,
userText,
callState.messages.slice(0, -1),
callState.initiator
);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('AI响应超时')), 30000)
);
const aiResponse = await Promise.race([aiPromise, timeoutPromise]);
// 清理回复
let reply = cleanAIReply(aiResponse);
callState.isProcessing = false;
if (!reply) {
updateStatus('通话中');
return;
}
// 添加 AI 消息
addCallMessage('ai', reply);
// TTS 并播放
await speakText(reply);
// 检查是否要挂断
if (detectHangupIntent(reply)) {
setTimeout(() => {
if (callState.isConnected) {
hangupCall();
}
}, 1500);
}
} catch (err) {
console.error('[可乐] 文字处理失败:', err);
callState.isProcessing = false;
updateStatus('通话中');
const errorMsg = err.message || '处理失败';
showToast('处理失败: ' + errorMsg.substring(0, 30), '⚠️');
}
}
/**
* 检测挂断意图
*/
function detectHangupIntent(text) {
if (!text) return false;
const hangupPatterns = [
/我(先)?挂了/,
/那我挂了/,
/先挂(了)?啊?/,
/挂了(啊|哈|呀|哦)?$/,
/拜拜.*挂/,
/再见.*挂/
];
return hangupPatterns.some(pattern => pattern.test(text));
}
/**
* 更新状态显示
*/
function updateStatus(text) {
const statusEl = document.getElementById('wechat-real-voice-call-status');
if (statusEl) {
statusEl.textContent = text;
}
}
/**
* 更新说话按钮状态
*/
function updateTalkButton(isRecording) {
const btn = document.getElementById('wechat-real-voice-call-talk-btn');
if (btn) {
if (isRecording) {
btn.classList.add('recording');
btn.textContent = '点击 发送';
} else {
btn.classList.remove('recording');
btn.textContent = '点击 说话';
}
}
}
/**
* 添加通话消息
*/
function addCallMessage(role, content) {
const messagesEl = document.getElementById('wechat-real-voice-call-messages');
if (!messagesEl) return;
callState.messages.push({ role, content });
const msgDiv = document.createElement('div');
msgDiv.className = `wechat-real-voice-call-msg ${role} fade-in`;
// 显示文字内容
const textSpan = document.createElement('span');
textSpan.className = 'msg-text';
textSpan.textContent = content;
msgDiv.appendChild(textSpan);
messagesEl.appendChild(msgDiv);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
/**
* 挂断电话(用户主动挂断)
*/
export async function hangupCall() {
// 如果已经在挂断中,忽略
if (callState.isHangingUp) return;
callState.isHangingUp = true;
// 先保存需要的值(后面状态会变)
const wasConnected = callState.isConnected;
const cachedVoices = callState.voiceCache ? [...callState.voiceCache] : [];
const contactIdx = callState.contactIndex;
const callTimestamp = callState.startTime || Date.now();
console.log('[可乐] 挂断时状态:', { wasConnected, voiceCacheLength: cachedVoices.length });
// 停止录音
if (callState.recorder) {
callState.recorder.cancel();
}
// 停止当前播放
if (callState.currentAudio) {
callState.currentAudio.pause();
callState.currentAudio = null;
}
// 如果通话已接通,让 AI 说再见
if (callState.isConnected && !callState.isProcessing) {
try {
updateStatus('对方正在说话...');
// 调用 AI 生成告别语
const { callRealVoiceAI } = await import('./ai.js');
const goodbyePrompt = '[用户正在挂断电话,请简短地说再见,一句话即可]';
const aiResponse = await Promise.race([
callRealVoiceAI(
callState.contact,
goodbyePrompt,
callState.messages,
callState.initiator
),
new Promise((_, reject) => setTimeout(() => reject(new Error('超时')), 5000))
]);
const reply = cleanAIReply(aiResponse);
if (reply) {
addCallMessage('ai', reply);
// TTS 播放告别语
await speakText(reply);
}
} catch (err) {
console.log('[可乐] AI告别语生成失败:', err.message);
}
}
// 计算通话时长
let durationStr = '00:00';
if (callState.isConnected && callState.startTime) {
const elapsed = Math.floor((Date.now() - callState.startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
durationStr = `${minutes}:${seconds}`;
}
// 添加通话记录到聊天历史
if (callState.contact) {
const contact = callState.contact;
if (!contact.chatHistory) {
contact.chatHistory = [];
}
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
let callContent;
let lastMessage;
if (callState.isConnected) {
callContent = `[实时语音:${durationStr}]`;
lastMessage = `实时语音 ${durationStr}`;
} else {
if (callState.initiator === 'user') {
if (callState.rejectedByAI) {
callContent = '[实时语音:对方已拒绝]';
lastMessage = '对方已拒绝';
} else {
callContent = '[实时语音:已取消]';
lastMessage = '已取消';
}
} else if (callState.rejectedByUser) {
callContent = '[实时语音:已拒绝]';
lastMessage = '已拒绝';
} else {
callContent = '[实时语音:对方已取消]';
lastMessage = '对方已取消';
}
}
// 通话记录消息
const callRecord = {
role: callState.initiator === 'user' ? 'user' : 'assistant',
content: callContent,
time: timeStr,
timestamp: Date.now(),
isCallRecord: true,
isRealVoice: true
};
contact.chatHistory.push(callRecord);
// 保存通话历史
if (callState.messages && callState.messages.length > 0) {
contact.realVoiceCallHistory = Array.isArray(contact.realVoiceCallHistory) ? contact.realVoiceCallHistory : [];
contact.realVoiceCallHistory.push({
type: 'real-voice',
initiator: callState.initiator,
duration: durationStr,
time: timeStr,
timestamp: Date.now(),
messages: callState.messages.map(m => ({ role: m.role, content: m.content }))
});
}
contact.lastMessage = lastMessage;
// 在聊天界面显示通话记录
if (currentChatIndex === callState.contactIndex) {
appendCallRecordMessage(callState.initiator === 'user' ? 'user' : 'assistant', durationStr, contact);
}
requestSave();
refreshChatList();
}
// 隐藏通话页面
const page = document.getElementById('wechat-real-voice-call-page');
if (page) {
page.classList.add('hidden');
}
clearInterval(callState.timerInterval);
clearInterval(callState.dotsInterval);
// 如果有缓存的语音,显示保存弹窗(使用之前保存的变量,因为 callState 可能已被修改)
console.log('[可乐] 检查是否显示语音保存弹窗:', { wasConnected, cachedVoicesLength: cachedVoices.length });
if (cachedVoices.length > 0 && wasConnected) {
// 重置状态
resetCallState();
// 显示语音保存弹窗
showVoiceSaveModal(cachedVoices, contactIdx, callTimestamp);
} else {
// 重置状态
resetCallState();
}
}
/**
* 重置通话状态
*/
function resetCallState() {
callState.isActive = false;
callState.isConnected = false;
callState.isHangingUp = false;
callState.startTime = null;
callState.isRecording = false;
callState.isProcessing = false;
callState.isPlaying = false;
callState.voiceCache = [];
}
/**
* 在聊天界面显示通话记录
*/
function appendCallRecordMessage(role, duration, contact) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`;
const firstChar = contact?.name ? contact.name.charAt(0) : '?';
let userAvatarContent = '我';
try {
const settings = getSettings();
if (settings.userAvatar) {
userAvatarContent = `
`;
}
} catch (e) {}
const avatarContent = role === 'user'
? userAvatarContent
: (contact?.avatar
? `
`
: firstChar);
// 麦克风图标
const micIconSVG = ``;
messageDiv.innerHTML = `