import { ref, onUnmounted, readonly } from 'vue'; const defaultExtractSpeechText = (text) => text; export function useTTSPlayer() { const synth = window.speechSynthesis; const isSpeaking = ref(false); const isPaused = ref(false); const utteranceRef = ref(null); const cleanup = () => { isSpeaking.value = false; isPaused.value = false; utteranceRef.value = null; }; /** * @param {string} text - The text to be spoken. * @param {object} [options] - Optional settings for the speech. * @param {string} [options.lang] - Language (e.g., 'en-US', 'es-ES'). * @param {number} [options.rate] - Speed (0.1 to 10, default 1). * @param {number} [options.pitch] - Pitch (0 to 2, default 1). * @param {SpeechSynthesisVoice} [options.voice] - A specific voice object. * @param {function(string): string} [options.extractSpeechText] - A function to filter/clean the text before speaking. */ const speak = (text, options = {}) => { if (!synth) { console.error('SpeechSynthesis API is not supported in this browser.'); return; } if (isSpeaking.value) { synth.cancel(); } const filteredText = extractSpeechText(text); if (!filteredText || typeof filteredText !== 'string' || filteredText.trim() === '') { console.warn('Text to speak is empty after filtering.'); cleanup(); // Ensure state is clean return; } const newUtterance = new SpeechSynthesisUtterance(filteredText); // Use filtered text utteranceRef.value = newUtterance; newUtterance.rate = options.rate || 1; newUtterance.pitch = options.pitch || 1; if (options.voice) { newUtterance.voice = options.voice; } newUtterance.onstart = () => { isSpeaking.value = true; isPaused.value = false; }; newUtterance.onpause = () => { isPaused.value = true; }; newUtterance.onresume = () => { isPaused.value = false; }; newUtterance.onend = () => { cleanup(); }; newUtterance.onerror = (event) => { console.error('SpeechSynthesis Error:', event.error); cleanup(); }; synth.speak(newUtterance); }; const pause = () => { if (synth && isSpeaking.value && !isPaused.value) { synth.pause(); } }; const resume = () => { if (synth && isPaused.value) { synth.resume(); } }; const cancelAudio = () => { if (synth) { synth.cancel(); } cleanup(); }; onUnmounted(() => { cancelAudio(); }); return { speak, pause, resume, cancelAudio, isSpeaking: readonly(isSpeaking), isPaused: readonly(isPaused), }; } function extractSpeechText(markdown) { const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g; const jobs = []; let match; let lastJobEndIndex = 0; let firstJobStartIndex = -1; // 提取岗位 json 数据及前后位置 while ((match = jobRegex.exec(markdown)) !== null) { const jobStr = match[1]; try { const job = JSON.parse(jobStr); jobs.push(job); if (firstJobStartIndex === -1) { firstJobStartIndex = match.index; } lastJobEndIndex = jobRegex.lastIndex; } catch (e) { console.warn('JSON 解析失败', e); } } // 提取引导语(第一个 job-json 之前的文字) const guideText = firstJobStartIndex > 0 ? markdown.slice(0, firstJobStartIndex).trim() : ''; // 提取结束语(最后一个 job-json 之后的文字) const endingText = lastJobEndIndex < markdown.length ? markdown.slice(lastJobEndIndex).trim() : ''; // 岗位信息格式化为语音文本 const jobTexts = jobs.map((job, index) => { return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`; }); // 拼接总语音内容 const finalTextParts = []; if (guideText) finalTextParts.push(guideText); finalTextParts.push(...jobTexts); if (endingText) finalTextParts.push(endingText); return finalTextParts.join('\n'); }