1143 lines
38 KiB
Vue
1143 lines
38 KiB
Vue
<script setup>
|
||
import { ref, computed, nextTick, onUnmounted, onMounted } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import { DOUBAO_APP_ID, DOUBAO_ACCESS_TOKEN, DOUBAO_RESOURCE_ID, DOUBAO_TTS_API_PATH, DOUBAO_AUDIO_FORMAT, DOUBAO_SAMPLE_RATE, ARK_API_KEY, ARK_MODEL, ARK_API_PATH, ARK_MAX_TOKENS, ARK_HISTORY_LIMIT } from "@/config/index.js";
|
||
import pako from "pako";
|
||
|
||
const router = useRouter();
|
||
|
||
// 场景配置
|
||
const scenes = [
|
||
{
|
||
id: "school",
|
||
name: "校园生活",
|
||
emoji: "🏫",
|
||
greeting: "Hi there! I'm your English buddy. Let's chat about school life — classes, teachers, friends, or anything that happens at school. It's a great way to practice everyday English! So, what subject do you like most at school?",
|
||
systemPrompt:
|
||
"You are a friendly English tutor for middle and high school students. Chat about school life topics like classes, homework, teachers, friends, and campus activities. Use simple, clear English suitable for teenagers. Keep responses to 2-3 sentences. Gently correct grammar mistakes and be encouraging.",
|
||
},
|
||
{
|
||
id: "hobby",
|
||
name: "兴趣爱好",
|
||
emoji: "🎮",
|
||
greeting: "Hey! I'd love to know more about you. Let's talk about your hobbies and interests — games, sports, music, drawing, reading... anything you enjoy! What do you like to do in your free time?",
|
||
systemPrompt:
|
||
"You are a friendly English tutor for middle and high school students. Discuss hobbies and interests like games, sports, music, art, and reading. Use casual, teen-friendly English. Keep responses to 2-3 sentences. Encourage the student to express themselves and gently fix any errors.",
|
||
},
|
||
{
|
||
id: "exam",
|
||
name: "英语考试",
|
||
emoji: "📝",
|
||
greeting: "Hello! Let's get ready for your English exam. I can help you practice speaking topics that often appear in school exams — like describing pictures, giving opinions, or talking about your daily routine. Let's start: Can you describe what you did last weekend?",
|
||
systemPrompt:
|
||
"You are an English exam coach for middle and high school students. Practice common exam speaking tasks: describing pictures, expressing opinions, talking about daily life, and answering topic questions. Use language appropriate for school exams. Give brief feedback after each response. Keep your replies concise.",
|
||
},
|
||
{
|
||
id: "movie",
|
||
name: "影视讨论",
|
||
emoji: "🎬",
|
||
greeting: "Lights, camera, English! Let's talk about movies, TV shows, or cartoons you enjoy. Discussing stories and characters is a super fun way to improve your English. Have you watched any good movies or shows recently?",
|
||
systemPrompt:
|
||
"You are a fun English tutor for teenagers. Discuss movies, TV shows, animations, and pop culture. Use engaging, youth-friendly language. Ask about plots, characters, and opinions. Keep responses to 2-3 sentences and gently correct any grammar mistakes.",
|
||
},
|
||
{
|
||
id: "travel",
|
||
name: "旅游英语",
|
||
emoji: "✈️",
|
||
greeting: "Welcome, young traveler! Imagine you're on a trip abroad — you might need to ask for directions, order food, or buy souvenirs. Let's practice! Pretend you just arrived in London. What's the first thing you'd like to do?",
|
||
systemPrompt:
|
||
"You are a helpful travel guide for teenage students. Role-play travel scenarios suitable for young learners: asking for directions, ordering at a restaurant, buying tickets, and checking into a hotel. Use simple and practical English. Keep responses short and easy to follow.",
|
||
},
|
||
];
|
||
|
||
// 英语音色列表
|
||
const voiceOptions = [
|
||
{ id: "en_female_dacey_uranus_bigtts", name: "Dacey (美音女)", avatar: "👩🏫" },
|
||
{ id: "en_male_tim_uranus_bigtts", name: "Tim (美音男)", avatar: "👨🏫" },
|
||
{ id: "en_female_stokie_uranus_bigtts", name: "Stokie (美音女)", avatar: "👩💼" },
|
||
{ id: "zh_female_yingyujiaoxue_uranus_bigtts", name: "Tina (英音女)", avatar: "👩🎓" },
|
||
];
|
||
|
||
const currentVoiceAvatar = computed(
|
||
() => voiceOptions.find((v) => v.id === selectedVoice.value)?.avatar ?? "🤖"
|
||
);
|
||
|
||
// 预设降级回复模板
|
||
const fallbackReplies = {
|
||
school: [
|
||
"That's interesting! Which teacher do you like the most and why? Talking about your favorite teacher is great English practice!",
|
||
"I see! Do you have a lot of homework these days? Tell me about your daily school routine.",
|
||
"Cool! What clubs or activities do you join at school? Extracurricular activities are a big part of school life.",
|
||
"Great answer! By the way, do you prefer studying alone or with friends? Both have their advantages!",
|
||
"Nice! Is there any subject you find really difficult? Don't worry — everyone has their challenges at school.",
|
||
],
|
||
hobby: [
|
||
"That sounds really fun! How long have you been doing that hobby? Practice makes perfect!",
|
||
"Awesome! Do you enjoy doing that with friends or by yourself? Hobbies are a great way to relax.",
|
||
"Cool choice! Have you ever joined a club or competition related to your hobby? It could be a great experience!",
|
||
"I love that! What got you started with that hobby in the first place? There's always an interesting story behind it.",
|
||
"Great! Do you think your hobby helps you in any way at school or in life? Many hobbies build useful skills!",
|
||
],
|
||
exam: [
|
||
"Good effort! Try to use more connecting words like 'firstly', 'however', and 'in conclusion' to make your answer sound more organized.",
|
||
"Nice try! Remember to give specific examples when answering opinion questions — it makes your answer much stronger.",
|
||
"Well done! One tip: speak at a steady pace and don't rush. Clear pronunciation matters more than speed in exams.",
|
||
"That's a solid answer! Try expanding it a little — add one more detail or reason to make it more complete.",
|
||
"Good job! In English exams, it helps to restate the question in your answer. For example, start with 'I think that...' or 'In my opinion...'",
|
||
],
|
||
movie: [
|
||
"Oh, that sounds like a great movie! Who is your favorite character in it and why? Describing characters is excellent English practice.",
|
||
"Interesting pick! What kind of movies do you usually enjoy — action, comedy, romance, or something else?",
|
||
"Nice! If you could change one thing about the ending of that movie, what would it be? Use your imagination!",
|
||
"Cool! Have you watched any English movies without subtitles? It's a great way to improve your listening skills.",
|
||
"Great taste! Would you recommend that movie to a friend? Try to describe it in 2-3 sentences — like a mini review!",
|
||
],
|
||
travel: [
|
||
"Great choice! When visiting a new city, it helps to say: 'Excuse me, could you recommend any must-see attractions nearby?'",
|
||
"Nice! At a restaurant abroad, you can order by saying: 'I'd like to have the pasta, please. Could I also get some water?'",
|
||
"Good thinking! If you get lost, try: 'Excuse me, I'm looking for the train station. Could you point me in the right direction?'",
|
||
"Awesome! When buying souvenirs, you can ask: 'How much does this cost? Do you have any discounts?'",
|
||
"Well done! Traveling is the best way to practice real English. Remember: a smile and 'please' and 'thank you' go a long way!",
|
||
],
|
||
};
|
||
|
||
const activeScene = ref("school");
|
||
const selectedVoice = ref("en_female_dacey_uranus_bigtts");
|
||
const messages = ref([]);
|
||
const inputText = ref("");
|
||
const isSending = ref(false);
|
||
const messagesContainer = ref(null);
|
||
let msgIdCounter = 0;
|
||
let currentAudioInstance = null;
|
||
const blobUrls = [];
|
||
let lastGreetedScene = null;
|
||
|
||
// ── 语音输入状态 ──
|
||
const isRecording = ref(false);
|
||
const asrStatus = ref(""); // 'connecting' | 'recording' | 'error' | ''
|
||
let asrWs = null;
|
||
let asrAudioContext = null;
|
||
let asrScriptProcessor = null;
|
||
let asrMediaStream = null;
|
||
let asrInterimText = ref(""); // 实时识别中间结果
|
||
|
||
// 兼容的 UUID 生成函数
|
||
const generateUUID = () => {
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||
const r = Math.random() * 16 | 0;
|
||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||
return v.toString(16);
|
||
});
|
||
};
|
||
|
||
const currentScene = computed(() => scenes.find((s) => s.id === activeScene.value));
|
||
|
||
// 初始化:发送默认场景欢迎语
|
||
onMounted(() => {
|
||
const defaultScene = scenes.find((s) => s.id === activeScene.value);
|
||
if (defaultScene) {
|
||
sendGreeting(defaultScene);
|
||
lastGreetedScene = activeScene.value;
|
||
}
|
||
});
|
||
|
||
// 发送场景欢迎语
|
||
const sendGreeting = async (scene) => {
|
||
isSending.value = true;
|
||
const greetingId = ++msgIdCounter;
|
||
const greetingMsg = { id: greetingId, role: "assistant", content: "", audioUrl: null, isPlaying: false, isLoading: true };
|
||
messages.value.push(greetingMsg);
|
||
await scrollToBottom();
|
||
// 短暂延迟模拟"思考"
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
const msg = messages.value.find((m) => m.id === greetingId);
|
||
if (msg) { msg.content = scene.greeting; msg.isLoading = false; }
|
||
await scrollToBottom();
|
||
try {
|
||
await synthesizeAndPlay(scene.greeting, greetingId);
|
||
} catch (e) {
|
||
console.error("Greeting TTS error:", e);
|
||
} finally {
|
||
isSending.value = false;
|
||
}
|
||
};
|
||
|
||
// 切换场景,重置对话并发送欢迎语
|
||
const switchScene = (sceneId) => {
|
||
// 如果是上一个已发送欢迎语的场景,不重复发送
|
||
if (activeScene.value === sceneId && lastGreetedScene === sceneId) return;
|
||
activeScene.value = sceneId;
|
||
// 停止当前播放
|
||
if (currentAudioInstance) {
|
||
currentAudioInstance.pause();
|
||
currentAudioInstance = null;
|
||
}
|
||
messages.value = [];
|
||
const scene = scenes.find((s) => s.id === sceneId);
|
||
if (scene) {
|
||
sendGreeting(scene);
|
||
lastGreetedScene = sceneId;
|
||
}
|
||
};
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = async () => {
|
||
await nextTick();
|
||
if (messagesContainer.value) {
|
||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||
}
|
||
};
|
||
|
||
// TTS 合成并播放
|
||
const synthesizeAndPlay = async (text, msgId) => {
|
||
const payload = {
|
||
user: { uid: "speaking_" + Date.now() },
|
||
req_params: {
|
||
text,
|
||
speaker: selectedVoice.value,
|
||
audio_params: { format: DOUBAO_AUDIO_FORMAT, sample_rate: DOUBAO_SAMPLE_RATE },
|
||
},
|
||
};
|
||
|
||
const response = await fetch(DOUBAO_TTS_API_PATH, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Api-App-Id": DOUBAO_APP_ID,
|
||
"X-Api-Access-Key": DOUBAO_ACCESS_TOKEN,
|
||
"X-Api-Resource-Id": DOUBAO_RESOURCE_ID,
|
||
"X-Api-Request-Id": generateUUID(),
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
if (!response.ok) throw new Error(`TTS HTTP ${response.status}`);
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
const audioChunks = [];
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop();
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const resData = JSON.parse(line);
|
||
if (resData.code === 0 && resData.data) {
|
||
const bin = window.atob(resData.data);
|
||
const bytes = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||
audioChunks.push(bytes);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
if (buffer.trim()) {
|
||
try {
|
||
const resData = JSON.parse(buffer);
|
||
if (resData.code === 0 && resData.data) {
|
||
const bin = window.atob(resData.data);
|
||
const bytes = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||
audioChunks.push(bytes);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
if (audioChunks.length === 0) return;
|
||
|
||
const totalLen = audioChunks.reduce((a, v) => a + v.length, 0);
|
||
const allBytes = new Uint8Array(totalLen);
|
||
let offset = 0;
|
||
for (const chunk of audioChunks) { allBytes.set(chunk, offset); offset += chunk.length; }
|
||
|
||
const blob = new Blob([allBytes], { type: "audio/mp3" });
|
||
const url = URL.createObjectURL(blob);
|
||
blobUrls.push(url);
|
||
|
||
// 更新消息的 audioUrl
|
||
const msg = messages.value.find((m) => m.id === msgId);
|
||
if (msg) msg.audioUrl = url;
|
||
|
||
playAudio(url, msgId);
|
||
};
|
||
|
||
// 播放音频
|
||
const playAudio = (url, msgId) => {
|
||
if (currentAudioInstance) {
|
||
currentAudioInstance.pause();
|
||
currentAudioInstance = null;
|
||
}
|
||
// 重置所有消息播放状态
|
||
messages.value.forEach((m) => (m.isPlaying = false));
|
||
|
||
const audio = new Audio(url);
|
||
currentAudioInstance = audio;
|
||
|
||
const msg = messages.value.find((m) => m.id === msgId);
|
||
if (msg) msg.isPlaying = true;
|
||
|
||
audio.onended = () => {
|
||
if (msg) msg.isPlaying = false;
|
||
if (currentAudioInstance === audio) currentAudioInstance = null;
|
||
};
|
||
audio.onerror = () => {
|
||
if (msg) msg.isPlaying = false;
|
||
};
|
||
audio.play();
|
||
};
|
||
|
||
// 重播某条消息
|
||
const replayMessage = (msg) => {
|
||
if (!msg.audioUrl) return;
|
||
if (msg.isPlaying) {
|
||
if (currentAudioInstance) { currentAudioInstance.pause(); currentAudioInstance = null; }
|
||
msg.isPlaying = false;
|
||
return;
|
||
}
|
||
playAudio(msg.audioUrl, msg.id);
|
||
};
|
||
|
||
// 调用 Ark 大模型
|
||
const callArkAPI = async (history) => {
|
||
const response = await fetch(ARK_API_PATH, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": `Bearer ${ARK_API_KEY}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: ARK_MODEL,
|
||
messages: [
|
||
{ role: "system", content: currentScene.value.systemPrompt },
|
||
...history,
|
||
],
|
||
max_tokens: ARK_MAX_TOKENS,
|
||
}),
|
||
});
|
||
if (!response.ok) throw new Error(`Ark HTTP ${response.status}`);
|
||
const data = await response.json();
|
||
return data.choices[0].message.content.trim();
|
||
};
|
||
|
||
// 获取降级回复
|
||
const getFallbackReply = () => {
|
||
const replies = fallbackReplies[activeScene.value] || fallbackReplies.free;
|
||
return replies[Math.floor(Math.random() * replies.length)];
|
||
};
|
||
|
||
// 发送消息
|
||
const sendMessage = async () => {
|
||
const text = inputText.value.trim();
|
||
if (!text || isSending.value) return;
|
||
|
||
inputText.value = "";
|
||
isSending.value = true;
|
||
|
||
// 插入用户消息
|
||
const userMsg = { id: ++msgIdCounter, role: "user", content: text, audioUrl: null, isPlaying: false, isLoading: false };
|
||
messages.value.push(userMsg);
|
||
await scrollToBottom();
|
||
|
||
// 插入 AI loading 占位
|
||
const aiMsgId = ++msgIdCounter;
|
||
const aiMsg = { id: aiMsgId, role: "assistant", content: "", audioUrl: null, isPlaying: false, isLoading: true };
|
||
messages.value.push(aiMsg);
|
||
await scrollToBottom();
|
||
|
||
try {
|
||
// 构建对话历史(最近20条)
|
||
const history = messages.value
|
||
.filter((m) => !m.isLoading && m.content)
|
||
.slice(-ARK_HISTORY_LIMIT)
|
||
.map((m) => ({ role: m.role, content: m.content }));
|
||
|
||
let replyText = "";
|
||
if (ARK_API_KEY) {
|
||
replyText = await callArkAPI(history.slice(0, -1).concat([{ role: "user", content: text }]));
|
||
} else {
|
||
// 模拟延迟
|
||
await new Promise((r) => setTimeout(r, 800));
|
||
replyText = getFallbackReply();
|
||
}
|
||
|
||
// 更新 AI 消息内容
|
||
const msg = messages.value.find((m) => m.id === aiMsgId);
|
||
if (msg) { msg.content = replyText; msg.isLoading = false; }
|
||
await scrollToBottom();
|
||
|
||
// TTS 合成
|
||
await synthesizeAndPlay(replyText, aiMsgId);
|
||
} catch (err) {
|
||
console.error("Speaking error:", err);
|
||
const msg = messages.value.find((m) => m.id === aiMsgId);
|
||
if (msg) { msg.content = "Sorry, something went wrong. Please try again."; msg.isLoading = false; }
|
||
} finally {
|
||
isSending.value = false;
|
||
await scrollToBottom();
|
||
}
|
||
};
|
||
|
||
const handleKeydown = (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendMessage();
|
||
}
|
||
};
|
||
|
||
// ── 语音输入(ASR)──
|
||
|
||
// 将 Float32 PCM 转为 Int16 PCM Uint8Array
|
||
const float32ToInt16Bytes = (float32Array) => {
|
||
const int16 = new Int16Array(float32Array.length);
|
||
for (let i = 0; i < float32Array.length; i++) {
|
||
const s = Math.max(-1, Math.min(1, float32Array[i]));
|
||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||
}
|
||
return new Uint8Array(int16.buffer);
|
||
};
|
||
|
||
// 构建二进制帧:4字节Header + 4字节Payload长度(大端) + Payload
|
||
// messageType: 0x01=Full Client Request, 0x02=Audio Only Request
|
||
// flags: 0x00=普通包, 0x02=最后一包
|
||
// serialization: 0x01=JSON, 0x00=Raw
|
||
// compression: 0x01=Gzip, 0x00=无压缩
|
||
const buildFrame = (messageType, flags, serialization, compression, payload) => {
|
||
const header = new Uint8Array([
|
||
(0x01 << 4) | 0x01, // byte0: version=1, headerSize=1(×4=4字节)
|
||
(messageType << 4) | flags, // byte1: messageType | flags
|
||
(serialization << 4) | compression, // byte2: serialization | compression
|
||
0x00, // byte3: reserved
|
||
]);
|
||
const sizeView = new DataView(new ArrayBuffer(4));
|
||
sizeView.setUint32(0, payload.length, false); // 大端序
|
||
const frame = new Uint8Array(4 + 4 + payload.length);
|
||
frame.set(header, 0);
|
||
frame.set(new Uint8Array(sizeView.buffer), 4);
|
||
frame.set(payload, 8);
|
||
return frame;
|
||
};
|
||
|
||
// 解析服务端返回的二进制帧
|
||
// Full Server Response 结构:Header(4) + Sequence(4) + PayloadSize(4) + Payload
|
||
// Error Response 结构:Header(4) + ErrorCode(4) + ErrorMsgSize(4) + ErrorMsg
|
||
const parseServerFrame = (buffer) => {
|
||
const view = new DataView(buffer);
|
||
const byte1 = view.getUint8(1);
|
||
const byte2 = view.getUint8(2);
|
||
const msgType = (byte1 >> 4) & 0x0f; // 0x09=Full Server Response, 0x0f=Error
|
||
const flags = byte1 & 0x0f;
|
||
const compression = byte2 & 0x0f;
|
||
|
||
// Error Response (msgType=0x0f)
|
||
if (msgType === 0x0f) {
|
||
const errCode = view.getUint32(4, false);
|
||
const errMsgSize = view.getUint32(8, false);
|
||
const errMsg = new TextDecoder().decode(new Uint8Array(buffer, 12, errMsgSize));
|
||
return { msgType, flags, error: true, code: errCode, message: errMsg };
|
||
}
|
||
|
||
// Full Server Response (msgType=0x09):Header(4) + Sequence(4) + PayloadSize(4) + Payload
|
||
const sequence = view.getInt32(4, false); // 有符号,负数表示最后一包
|
||
const payloadSize = view.getUint32(8, false);
|
||
const payloadBytes = new Uint8Array(buffer, 12, payloadSize);
|
||
|
||
let jsonStr;
|
||
if (compression === 0x01) {
|
||
jsonStr = new TextDecoder().decode(pako.ungzip(payloadBytes));
|
||
} else {
|
||
jsonStr = new TextDecoder().decode(payloadBytes);
|
||
}
|
||
|
||
return { msgType, flags, sequence, data: JSON.parse(jsonStr) };
|
||
};
|
||
|
||
// 停止录音并关闭 WebSocket,autoSend=true 时识别完成后自动发送
|
||
const stopRecording = (autoSend = false) => {
|
||
isRecording.value = false;
|
||
asrStatus.value = "";
|
||
asrInterimText.value = ""; // 清除中间结果提示,等待服务端最终结果
|
||
|
||
if (asrScriptProcessor) {
|
||
asrScriptProcessor.disconnect();
|
||
asrScriptProcessor = null;
|
||
}
|
||
if (asrAudioContext) {
|
||
asrAudioContext.close().catch(() => {});
|
||
asrAudioContext = null;
|
||
}
|
||
if (asrMediaStream) {
|
||
asrMediaStream.getTracks().forEach((t) => t.stop());
|
||
asrMediaStream = null;
|
||
}
|
||
if (asrWs) {
|
||
if (asrWs.readyState === WebSocket.OPEN) {
|
||
try {
|
||
// 发送结束帧,服务端收到后会返回最终识别结果再关闭连接
|
||
const emptyGzip = pako.gzip(new Uint8Array(0));
|
||
const endFrame = buildFrame(0x02, 0x02, 0x00, 0x01, emptyGzip);
|
||
asrWs.send(endFrame);
|
||
} catch (e) {}
|
||
|
||
if (autoSend) {
|
||
// 等服务端返回最终结果并关闭连接后再发送
|
||
asrWs.onclose = () => {
|
||
asrWs = null;
|
||
if (inputText.value.trim()) sendMessage();
|
||
};
|
||
return; // 延迟关闭,由服务端主动断开
|
||
}
|
||
}
|
||
asrWs.close();
|
||
asrWs = null;
|
||
} else if (autoSend && inputText.value.trim()) {
|
||
sendMessage();
|
||
}
|
||
};
|
||
|
||
// 开始录音
|
||
const startRecording = async () => {
|
||
if (isRecording.value) {
|
||
stopRecording(true); // 停止录音并自动发送
|
||
return;
|
||
}
|
||
|
||
// 检查浏览器支持
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||
alert("您的浏览器不支持麦克风录音功能");
|
||
return;
|
||
}
|
||
|
||
asrStatus.value = "connecting";
|
||
asrInterimText.value = "";
|
||
|
||
try {
|
||
// 1. 通过 Vite 代理连接(代理层自动注入鉴权 Header,绕过浏览器 WS 不支持自定义 Header 的限制)
|
||
const wsUrl = `ws://${location.host}/asr-ws/api/v3/sauc/bigmodel`;
|
||
const ws = new WebSocket(wsUrl);
|
||
ws.binaryType = "arraybuffer";
|
||
asrWs = ws;
|
||
|
||
await new Promise((resolve, reject) => {
|
||
ws.onopen = () => resolve();
|
||
ws.onerror = () => reject(new Error("WebSocket 连接失败"));
|
||
setTimeout(() => reject(new Error("WebSocket 连接超时")), 8000);
|
||
});
|
||
|
||
// 2. 发送初始化配置帧(Full Client Request,JSON+Gzip)
|
||
const initPayload = {
|
||
user: { uid: "speaking_asr_" + Date.now() },
|
||
audio: {
|
||
format: "pcm",
|
||
rate: 16000,
|
||
bits: 16,
|
||
channel: 1,
|
||
},
|
||
request: {
|
||
model_name: "bigmodel",
|
||
enable_punc: true,
|
||
enable_itn: true,
|
||
},
|
||
};
|
||
const compressedInit = pako.gzip(JSON.stringify(initPayload));
|
||
// Full Client Request(0x01), flags=0x00, JSON(0x01), Gzip(0x01)
|
||
ws.send(buildFrame(0x01, 0x00, 0x01, 0x01, compressedInit));
|
||
|
||
// 3. 监听识别结果(二进制响应)
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const parsed = parseServerFrame(event.data);
|
||
|
||
// 错误帧
|
||
if (parsed.error) {
|
||
console.error("ASR server error:", parsed.code, parsed.message);
|
||
return;
|
||
}
|
||
|
||
const { data } = parsed;
|
||
// 检查业务错误码(20000000 = 成功)
|
||
if (data?.code && data.code !== 20000000) {
|
||
console.error("ASR error code:", data.code, data.message);
|
||
return;
|
||
}
|
||
|
||
const result = data?.result;
|
||
if (!result) return;
|
||
|
||
const text = result.text || "";
|
||
if (!text) return;
|
||
|
||
// 通过 utterances[].definite 判断是否为确定分句(最终结果)
|
||
const hasFinal = result.utterances?.some((u) => u.definite === true);
|
||
if (hasFinal) {
|
||
// 最终结果:清空中间结果,将本句完整文本追加到输入框
|
||
asrInterimText.value = "";
|
||
inputText.value = (inputText.value + " " + text).trim();
|
||
} else {
|
||
// 中间结果:只更新提示,不写入输入框
|
||
asrInterimText.value = text;
|
||
}
|
||
} catch (e) {
|
||
console.error("ASR parse error:", e);
|
||
}
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
console.error("ASR WebSocket error");
|
||
stopRecording();
|
||
asrStatus.value = "error";
|
||
setTimeout(() => { asrStatus.value = ""; }, 3000);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
// 异常断开时清理(正常停止录音时 stopRecording 已处理)
|
||
if (isRecording.value) stopRecording();
|
||
};
|
||
|
||
// 4. 获取麦克风并采集 PCM
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||
});
|
||
asrMediaStream = stream;
|
||
|
||
const AudioContextClass = window.AudioContext || window["webkitAudioContext"];
|
||
const audioCtx = new AudioContextClass({ sampleRate: 16000 });
|
||
asrAudioContext = audioCtx;
|
||
|
||
const source = audioCtx.createMediaStreamSource(stream);
|
||
// 每次处理 2048 帧 ≈ 128ms @ 16kHz(2的幂次方,符合 API 建议的 100~200ms 分包)
|
||
const processor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||
asrScriptProcessor = processor;
|
||
|
||
processor.onaudioprocess = (e) => {
|
||
if (!isRecording.value || !asrWs || asrWs.readyState !== WebSocket.OPEN) return;
|
||
const pcmBytes = float32ToInt16Bytes(e.inputBuffer.getChannelData(0));
|
||
// Audio Only Request(0x02), flags=0x00, Raw(0x00), Gzip(0x01)
|
||
const compressed = pako.gzip(pcmBytes);
|
||
asrWs.send(buildFrame(0x02, 0x00, 0x00, 0x01, compressed));
|
||
};
|
||
|
||
source.connect(processor);
|
||
processor.connect(audioCtx.destination);
|
||
|
||
isRecording.value = true;
|
||
asrStatus.value = "recording";
|
||
} catch (err) {
|
||
console.error("ASR start error:", err);
|
||
stopRecording();
|
||
asrStatus.value = "error";
|
||
setTimeout(() => { asrStatus.value = ""; }, 3000);
|
||
if (err.name === "NotAllowedError") {
|
||
alert("麦克风权限被拒绝,请在浏览器设置中允许访问麦克风");
|
||
}
|
||
}
|
||
};
|
||
|
||
onUnmounted(() => {
|
||
if (currentAudioInstance) { currentAudioInstance.pause(); currentAudioInstance = null; }
|
||
blobUrls.forEach((url) => URL.revokeObjectURL(url));
|
||
stopRecording();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-container">
|
||
<!-- 顶部导航 -->
|
||
<nav class="nav-bar">
|
||
<button @click="router.push('/')" class="back-btn">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||
</svg>
|
||
返回首页
|
||
</button>
|
||
<div class="nav-title">AI 口语对话</div>
|
||
<div class="voice-select-wrap">
|
||
<select v-model="selectedVoice" class="voice-select">
|
||
<option v-for="v in voiceOptions" :key="v.id" :value="v.id">{{ v.name }}</option>
|
||
</select>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- 场景选择 -->
|
||
<div class="scene-tabs">
|
||
<button
|
||
v-for="scene in scenes"
|
||
:key="scene.id"
|
||
class="scene-tab"
|
||
:class="{ active: activeScene === scene.id }"
|
||
@click="switchScene(scene.id)"
|
||
>
|
||
<span class="scene-emoji">{{ scene.emoji }}</span>
|
||
{{ scene.name }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 聊天区域 -->
|
||
<div class="chat-area" ref="messagesContainer">
|
||
<!-- 欢迎提示 -->
|
||
<div v-if="messages.length === 0" class="welcome-hint">
|
||
<div class="welcome-icon">{{ currentScene.emoji }}</div>
|
||
<h3>{{ currentScene.name }}</h3>
|
||
<p>开始输入,与 AI 外教展开英语对话练习吧!</p>
|
||
</div>
|
||
|
||
<!-- 消息列表 -->
|
||
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="msg.role">
|
||
<!-- AI 头像 -->
|
||
<div v-if="msg.role === 'assistant'" class="avatar">{{ currentVoiceAvatar }}</div>
|
||
|
||
<div class="bubble-wrap">
|
||
<!-- 气泡 -->
|
||
<div class="bubble" :class="msg.role">
|
||
<!-- loading 动画 -->
|
||
<div v-if="msg.isLoading" class="loading-dots">
|
||
<span></span><span></span><span></span>
|
||
</div>
|
||
<span v-else>{{ msg.content }}</span>
|
||
</div>
|
||
|
||
<!-- AI 消息播放按钮 -->
|
||
<div v-if="msg.role === 'assistant' && !msg.isLoading && msg.content" class="audio-controls">
|
||
<button class="play-btn" :class="{ playing: msg.isPlaying }" @click="replayMessage(msg)" :title="msg.isPlaying ? '暂停' : '播放'">
|
||
<!-- 播放中:音波动画 -->
|
||
<div v-if="msg.isPlaying" class="wave-bars">
|
||
<span></span><span></span><span></span><span></span>
|
||
</div>
|
||
<!-- 未播放:播放图标 -->
|
||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||
</svg>
|
||
</button>
|
||
<span v-if="!msg.audioUrl && !msg.isLoading" class="tts-hint">合成中...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 用户头像 -->
|
||
<div v-if="msg.role === 'user'" class="avatar user-avatar">🧑💻</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部输入区 -->
|
||
<div class="input-area">
|
||
<textarea
|
||
v-model="inputText"
|
||
class="input-box"
|
||
:placeholder="asrInterimText ? asrInterimText : '输入英语内容,按 Enter 发送,Shift+Enter 换行...'"
|
||
:disabled="isSending"
|
||
@keydown="handleKeydown"
|
||
rows="1"
|
||
></textarea>
|
||
<!-- 麦克风按钮 -->
|
||
<button
|
||
class="mic-btn"
|
||
:class="{ recording: isRecording, error: asrStatus === 'error', connecting: asrStatus === 'connecting' }"
|
||
@click="startRecording"
|
||
:title="isRecording ? '停止录音' : '语音输入'"
|
||
:disabled="isSending"
|
||
>
|
||
<!-- 连接中:旋转动画 -->
|
||
<div v-if="asrStatus === 'connecting'" class="mic-spinner"></div>
|
||
<!-- 录音中:麦克风 + 脉冲 -->
|
||
<template v-else-if="isRecording">
|
||
<div class="mic-pulse"></div>
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"/>
|
||
</svg>
|
||
</template>
|
||
<!-- 默认:麦克风图标 -->
|
||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"/>
|
||
</svg>
|
||
</button>
|
||
<button class="send-btn" :disabled="!inputText.trim() || isSending" @click="sendMessage">
|
||
<svg v-if="!isSending" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||
</svg>
|
||
<div v-else class="send-spinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
* { box-sizing: border-box; }
|
||
|
||
.page-container {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
padding: 2rem 2rem 0;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* Nav */
|
||
.nav-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 1.25rem;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.back-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-secondary);
|
||
font-size: 1rem;
|
||
cursor: pointer;
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 8px;
|
||
transition: all 0.2s;
|
||
white-space: nowrap;
|
||
}
|
||
.back-btn:hover { background: var(--card-bg); color: var(--text-primary); }
|
||
.back-btn svg { width: 20px; height: 20px; }
|
||
|
||
.nav-title {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-size: 1.5rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.voice-select-wrap { min-width: 180px; }
|
||
.voice-select {
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
background: var(--card-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 0.6rem center;
|
||
border: 1px solid var(--card-border);
|
||
color: var(--text-primary);
|
||
padding: 0.4rem 2rem 0.4rem 0.75rem;
|
||
border-radius: 8px;
|
||
font-size: 0.875rem;
|
||
cursor: pointer;
|
||
outline: none;
|
||
width: 100%;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.voice-select:hover { border-color: var(--accent-1); }
|
||
.voice-select option {
|
||
background: #1e1e2e;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* Scene Tabs */
|
||
.scene-tabs {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 1.25rem;
|
||
overflow-x: auto;
|
||
padding-bottom: 0.25rem;
|
||
}
|
||
.scene-tabs::-webkit-scrollbar { height: 4px; }
|
||
.scene-tabs::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||
|
||
.scene-tab {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
background: rgba(255,255,255,0.05);
|
||
border: 1px solid transparent;
|
||
color: var(--text-secondary);
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 20px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
font-size: 0.9rem;
|
||
transition: all 0.2s;
|
||
}
|
||
.scene-tab:hover { background: rgba(255,255,255,0.1); color: var(--text-primary); }
|
||
.scene-tab.active {
|
||
background: rgba(99,102,241,0.15);
|
||
border-color: rgba(99,102,241,0.4);
|
||
color: var(--accent-1);
|
||
}
|
||
.scene-emoji { font-size: 1rem; }
|
||
|
||
/* Chat Area */
|
||
.chat-area {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 1rem 0.5rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1.25rem;
|
||
min-height: 0;
|
||
}
|
||
.chat-area::-webkit-scrollbar { width: 6px; }
|
||
.chat-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||
|
||
/* Welcome */
|
||
.welcome-hint {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 1;
|
||
gap: 0.75rem;
|
||
color: var(--text-secondary);
|
||
text-align: center;
|
||
padding: 3rem 0;
|
||
}
|
||
.welcome-icon { font-size: 3.5rem; }
|
||
.welcome-hint h3 { margin: 0; font-size: 1.25rem; color: var(--text-primary); }
|
||
.welcome-hint p { margin: 0; font-size: 0.95rem; }
|
||
|
||
/* Message Row */
|
||
.message-row {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 0.75rem;
|
||
}
|
||
.message-row.user { flex-direction: row-reverse; }
|
||
|
||
.avatar {
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 50%;
|
||
background: rgba(99,102,241,0.15);
|
||
border: 1px solid rgba(99,102,241,0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.2rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.user-avatar { background: rgba(139,92,246,0.15); border-color: rgba(139,92,246,0.3); }
|
||
|
||
.bubble-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
max-width: 70%;
|
||
}
|
||
.message-row.user .bubble-wrap { align-items: flex-end; }
|
||
|
||
.bubble {
|
||
padding: 0.875rem 1.125rem;
|
||
border-radius: 18px;
|
||
font-size: 1rem;
|
||
line-height: 1.6;
|
||
word-break: break-word;
|
||
animation: bubbleIn 0.25s ease-out;
|
||
}
|
||
.bubble.assistant {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--card-border);
|
||
border-bottom-left-radius: 4px;
|
||
color: var(--text-primary);
|
||
}
|
||
.bubble.user {
|
||
background: linear-gradient(135deg, var(--accent-1), #7c3aed);
|
||
border-bottom-right-radius: 4px;
|
||
color: white;
|
||
}
|
||
|
||
@keyframes bubbleIn {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* Loading dots */
|
||
.loading-dots {
|
||
display: flex;
|
||
gap: 5px;
|
||
align-items: center;
|
||
padding: 0.25rem 0;
|
||
}
|
||
.loading-dots span {
|
||
width: 8px; height: 8px;
|
||
background: var(--text-secondary);
|
||
border-radius: 50%;
|
||
animation: dotBounce 1.2s infinite ease-in-out;
|
||
}
|
||
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||
@keyframes dotBounce {
|
||
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
|
||
40% { transform: scale(1); opacity: 1; }
|
||
}
|
||
|
||
/* Audio Controls */
|
||
.audio-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.play-btn {
|
||
width: 30px; height: 30px;
|
||
border-radius: 50%;
|
||
background: rgba(99,102,241,0.15);
|
||
border: 1px solid rgba(99,102,241,0.3);
|
||
color: var(--accent-1);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.play-btn:hover { background: rgba(99,102,241,0.3); transform: scale(1.1); }
|
||
.play-btn.playing { background: rgba(99,102,241,0.25); border-color: var(--accent-1); }
|
||
.play-btn svg { width: 14px; height: 14px; }
|
||
|
||
.tts-hint { font-size: 0.75rem; color: var(--text-secondary); }
|
||
|
||
/* Wave bars animation */
|
||
.wave-bars {
|
||
display: flex;
|
||
gap: 2px;
|
||
height: 14px;
|
||
align-items: center;
|
||
}
|
||
.wave-bars span {
|
||
width: 3px;
|
||
background: var(--accent-1);
|
||
border-radius: 2px;
|
||
animation: waveBounce 0.6s infinite alternate;
|
||
}
|
||
.wave-bars span:nth-child(2) { animation-delay: 0.15s; }
|
||
.wave-bars span:nth-child(3) { animation-delay: 0.3s; }
|
||
.wave-bars span:nth-child(4) { animation-delay: 0.45s; }
|
||
@keyframes waveBounce {
|
||
from { height: 3px; }
|
||
to { height: 14px; }
|
||
}
|
||
|
||
/* Input Area */
|
||
.input-area {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 0.75rem;
|
||
padding: 1.25rem 0 1.5rem;
|
||
border-top: 1px solid var(--card-border);
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.input-box {
|
||
flex: 1;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--card-border);
|
||
border-radius: 14px;
|
||
padding: 0.875rem 1.25rem;
|
||
color: var(--text-primary);
|
||
font-family: inherit;
|
||
font-size: 1rem;
|
||
line-height: 1.5;
|
||
resize: none;
|
||
outline: none;
|
||
transition: border-color 0.2s;
|
||
max-height: 120px;
|
||
overflow-y: auto;
|
||
}
|
||
.input-box:focus { border-color: var(--accent-1); }
|
||
.input-box:disabled { opacity: 0.6; cursor: not-allowed; }
|
||
|
||
.send-btn {
|
||
width: 46px; height: 46px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, var(--accent-1), #7c3aed);
|
||
border: none;
|
||
color: white;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 4px 12px rgba(99,102,241,0.35);
|
||
}
|
||
.send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 16px rgba(99,102,241,0.5); }
|
||
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||
.send-btn svg { width: 20px; height: 20px; }
|
||
|
||
.send-spinner {
|
||
width: 18px; height: 18px;
|
||
border: 2px solid rgba(255,255,255,0.3);
|
||
border-top-color: white;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* Mic Button */
|
||
.mic-btn {
|
||
position: relative;
|
||
width: 46px; height: 46px;
|
||
border-radius: 50%;
|
||
background: rgba(255,255,255,0.07);
|
||
border: 1px solid var(--card-border);
|
||
color: var(--text-secondary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
}
|
||
.mic-btn svg { width: 20px; height: 20px; position: relative; z-index: 1; }
|
||
.mic-btn:hover:not(:disabled) { background: rgba(255,255,255,0.12); color: var(--text-primary); border-color: rgba(255,255,255,0.2); }
|
||
.mic-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
/* 录音中状态 */
|
||
.mic-btn.recording {
|
||
background: rgba(239,68,68,0.15);
|
||
border-color: rgba(239,68,68,0.5);
|
||
color: #ef4444;
|
||
}
|
||
.mic-btn.recording:hover:not(:disabled) { background: rgba(239,68,68,0.25); }
|
||
|
||
/* 连接中状态 */
|
||
.mic-btn.connecting {
|
||
background: rgba(251,191,36,0.1);
|
||
border-color: rgba(251,191,36,0.4);
|
||
color: #fbbf24;
|
||
}
|
||
|
||
/* 错误状态 */
|
||
.mic-btn.error {
|
||
background: rgba(239,68,68,0.1);
|
||
border-color: rgba(239,68,68,0.4);
|
||
color: #ef4444;
|
||
}
|
||
|
||
/* 脉冲动画 */
|
||
.mic-pulse {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 50%;
|
||
background: rgba(239,68,68,0.2);
|
||
animation: micPulse 1.2s ease-out infinite;
|
||
}
|
||
@keyframes micPulse {
|
||
0% { transform: scale(0.85); opacity: 0.8; }
|
||
70% { transform: scale(1.15); opacity: 0; }
|
||
100% { transform: scale(0.85); opacity: 0; }
|
||
}
|
||
|
||
/* 连接中旋转 */
|
||
.mic-spinner {
|
||
width: 18px; height: 18px;
|
||
border: 2px solid rgba(251,191,36,0.3);
|
||
border-top-color: #fbbf24;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.page-container { padding: 1rem 1rem 0; }
|
||
.bubble-wrap { max-width: 85%; }
|
||
.nav-title { font-size: 1.2rem; }
|
||
}
|
||
</style>
|
||
|