SORA
有限会社アライブ 総合受付AI
⚙ 設定
音声エンジン
速度: ±0%(0=標準)

※ キーは Worker 環境変数(AZURE_TTS_KEY / AZURE_TTS_REGION)に設定すると
フロントに入力不要でより安全です。
Azure Portal → Speech サービス → キーとエンドポイント

速度: 1.1

※ Worker 環境変数(GC_TTS_KEY)に設定するとより安全です。
Google Cloud Console → Cloud Text-to-Speech API

速度: 1.1

※ PCやスマホに内蔵の音声エンジンを使用。品質はデバイスに依存します。

口パク動画
話中に口パク動画を再生
function loadCfg() { try { const s = localStorage.getItem('sora_cfg'); if (s) cfg = { ...cfg, ...JSON.parse(s) }; } catch(e) {} applySettingsUI(); } function saveCfg() { localStorage.setItem('sora_cfg', JSON.stringify(cfg)); } // ---- 設定UIへ反映 ---- function applySettingsUI() { // エンジン選択 document.querySelectorAll('.engine-option').forEach(el => { const v = el.dataset.engine; el.classList.toggle('active', v === cfg.engine); el.querySelector('input[type=radio]').checked = (v === cfg.engine); }); switchEnginePanel(cfg.engine); // ブラウザTTS document.getElementById('speed-slider').value = cfg.browserSpeed; document.getElementById('speed-val').textContent = cfg.browserSpeed; populateBrowserVoices(); // Google TTS document.getElementById('gc-apikey').value = cfg.gcApiKey; document.getElementById('gc-voice').value = cfg.gcVoice; document.getElementById('gc-speed').value = cfg.gcSpeed; document.getElementById('gc-speed-val').textContent = cfg.gcSpeed; // VOICEVOX document.getElementById('vv-url').value = cfg.vvUrl; document.getElementById('vv-speaker').value = cfg.vvSpeaker; document.getElementById('vv-speed').value = cfg.vvSpeed; document.getElementById('vv-speed-val').textContent = cfg.vvSpeed; document.getElementById('vv-pitch').value = cfg.vvPitch; document.getElementById('vv-pitch-val').textContent = cfg.vvPitch; // 口パク talkToggle.classList.toggle('on', cfg.talkingVideo); } // ---- エンジンパネル切り替え ---- function switchEnginePanel(engine) { ['browser', 'google', 'voicevox'].forEach(e => { document.getElementById(`panel-${e}`)?.classList.toggle('visible', e === engine); }); } // ---- エンジン選択イベント ---- document.querySelectorAll('.engine-option').forEach(el => { el.addEventListener('click', () => { const v = el.dataset.engine; cfg.engine = v; document.querySelectorAll('.engine-option').forEach(o => { o.classList.toggle('active', o.dataset.engine === v); }); switchEnginePanel(v); }); }); // ---- ブラウザTTS ボイス一覧 ---- function populateBrowserVoices() { const sel = document.getElementById('browser-voice'); const voices = window.speechSynthesis?.getVoices() ?? []; const jaVoices = voices.filter(v => v.lang.startsWith('ja')); sel.innerHTML = ''; jaVoices.forEach(v => { const opt = document.createElement('option'); opt.value = v.name; opt.textContent = v.name; if (v.name === cfg.browserVoice) opt.selected = true; sel.appendChild(opt); }); } if ('speechSynthesis' in window) { window.speechSynthesis.onvoiceschanged = populateBrowserVoices; } // ---- スライダー同期 ---- document.getElementById('speed-slider').addEventListener('input', e => { cfg.browserSpeed = parseFloat(e.target.value); document.getElementById('speed-val').textContent = cfg.browserSpeed; }); document.getElementById('gc-speed').addEventListener('input', e => { cfg.gcSpeed = parseFloat(e.target.value); document.getElementById('gc-speed-val').textContent = cfg.gcSpeed; }); document.getElementById('vv-speed').addEventListener('input', e => { cfg.vvSpeed = parseFloat(e.target.value); document.getElementById('vv-speed-val').textContent = cfg.vvSpeed; }); document.getElementById('vv-pitch').addEventListener('input', e => { cfg.vvPitch = parseFloat(e.target.value); document.getElementById('vv-pitch-val').textContent = cfg.vvPitch; }); // ---- 口パクトグル ---- talkToggle.addEventListener('click', () => { cfg.talkingVideo = !cfg.talkingVideo; talkToggle.classList.toggle('on', cfg.talkingVideo); }); // ---- 設定保存 ---- saveSettings.addEventListener('click', () => { cfg.gcApiKey = document.getElementById('gc-apikey').value.trim(); cfg.gcVoice = document.getElementById('gc-voice').value; cfg.vvUrl = document.getElementById('vv-url').value.trim() || 'http://localhost:50021'; cfg.vvSpeaker = parseInt(document.getElementById('vv-speaker').value); cfg.browserVoice = document.getElementById('browser-voice').value; saveCfg(); settingsModal.classList.remove('open'); showToast('設定を保存しました'); }); settingsBtn.addEventListener('click', () => settingsModal.classList.add('open')); closeSettings.addEventListener('click', () => settingsModal.classList.remove('open')); // ---- トースト通知 ---- function showToast(msg) { const t = document.createElement('div'); t.style.cssText = ` position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:rgba(58,155,213,0.9); color:white; padding:8px 20px; border-radius:20px; font-size:13px; z-index:9999; animation: fadeUp 0.3s ease; `; t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 2000); } // ============================================================ // 口パク制御 // ============================================================ function startTalking() { if (!cfg.talkingVideo) return; videoTalking.currentTime = 0; videoTalking.play().catch(() => {}); videoIdle.style.opacity = '0'; videoTalking.style.opacity = '1'; } function stopTalking() { videoTalking.pause(); videoIdle.style.opacity = '1'; videoTalking.style.opacity = '0'; } // ============================================================ // TTS エンジン群 // ============================================================ // ---- ブラウザTTS ---- let currentUtterance = null; function speakBrowser(text, onEnd) { window.speechSynthesis.cancel(); const utter = new SpeechSynthesisUtterance(text); utter.lang = 'ja-JP'; utter.rate = cfg.browserSpeed; const voices = window.speechSynthesis.getVoices(); const target = cfg.browserVoice ? voices.find(v => v.name === cfg.browserVoice) : voices.find(v => v.lang === 'ja-JP' || v.lang.startsWith('ja')); if (target) utter.voice = target; utter.onstart = () => startTalking(); utter.onend = () => { stopTalking(); onEnd && onEnd(); }; utter.onerror = () => { stopTalking(); onEnd && onEnd(); }; currentUtterance = utter; window.speechSynthesis.speak(utter); } // ---- Google Cloud TTS ---- async function speakGoogle(text, onEnd) { const apiKey = cfg.gcApiKey; if (!apiKey) { showToast('Google TTS: APIキーが未設定です'); onEnd && onEnd(); return; } try { startTalking(); const res = await fetch( `https://texttospeech.googleapis.com/v1/text:synthesize?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: { text }, voice: { languageCode: 'ja-JP', name: cfg.gcVoice }, audioConfig: { audioEncoding: 'MP3', speakingRate: cfg.gcSpeed }, }), } ); const data = await res.json(); if (!data.audioContent) throw new Error('no audio'); const audio = new Audio('data:audio/mp3;base64,' + data.audioContent); audio.onended = () => { stopTalking(); onEnd && onEnd(); }; audio.onerror = () => { stopTalking(); onEnd && onEnd(); }; await audio.play(); } catch(e) { stopTalking(); showToast('Google TTS エラー: ' + e.message); onEnd && onEnd(); } } // ---- VOICEVOX ---- async function speakVoicevox(text, onEnd) { const base = cfg.vvUrl.replace(/\/$/, ''); try { startTalking(); // 1. audio_query const qRes = await fetch( `${base}/audio_query?text=${encodeURIComponent(text)}&speaker=${cfg.vvSpeaker}`, { method: 'POST' } ); if (!qRes.ok) throw new Error(`audio_query: ${qRes.status}`); const query = await qRes.json(); // パラメータ上書き query.speedScale = cfg.vvSpeed; query.pitchScale = cfg.vvPitch; // 2. synthesis const sRes = await fetch( `${base}/synthesis?speaker=${cfg.vvSpeaker}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query), } ); if (!sRes.ok) throw new Error(`synthesis: ${sRes.status}`); const blob = await sRes.blob(); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.onended = () => { stopTalking(); URL.revokeObjectURL(url); onEnd && onEnd(); }; audio.onerror = () => { stopTalking(); URL.revokeObjectURL(url); onEnd && onEnd(); }; await audio.play(); } catch(e) { stopTalking(); showToast('VOICEVOX エラー: ' + e.message); onEnd && onEnd(); } } // ---- 統合 speak() ---- function speak(text, onEnd) { if (cfg.engine === 'off') { onEnd && onEnd(); return; } if (cfg.engine === 'google') { speakGoogle(text, onEnd); return; } if (cfg.engine === 'voicevox') { speakVoicevox(text, onEnd); return; } // default: browser if ('speechSynthesis' in window) { speakBrowser(text, onEnd); } else { onEnd && onEnd(); } } // ---- テスト再生ボタン ---- document.getElementById('test-browser').addEventListener('click', () => { cfg.browserSpeed = parseFloat(document.getElementById('speed-slider').value); cfg.browserVoice = document.getElementById('browser-voice').value; speakBrowser('こんにちは!私はSORAです。', () => {}); }); document.getElementById('test-google').addEventListener('click', () => { cfg.gcApiKey = document.getElementById('gc-apikey').value.trim(); cfg.gcVoice = document.getElementById('gc-voice').value; cfg.gcSpeed = parseFloat(document.getElementById('gc-speed').value); speakGoogle('こんにちは!私はSORAです。', () => {}); }); document.getElementById('test-voicevox').addEventListener('click', () => { cfg.vvUrl = document.getElementById('vv-url').value.trim() || 'http://localhost:50021'; cfg.vvSpeaker = parseInt(document.getElementById('vv-speaker').value); cfg.vvSpeed = parseFloat(document.getElementById('vv-speed').value); cfg.vvPitch = parseFloat(document.getElementById('vv-pitch').value); speakVoicevox('こんにちは!私はSORAです。', () => {}); }); // ---- VOICEVOX 話者一覧取得 ---- document.getElementById('fetch-vv-speakers').addEventListener('click', async () => { const base = (document.getElementById('vv-url').value.trim() || 'http://localhost:50021').replace(/\/$/, ''); try { const res = await fetch(`${base}/speakers`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const sel = document.getElementById('vv-speaker'); sel.innerHTML = ''; data.forEach(speaker => { speaker.styles.forEach(style => { const opt = document.createElement('option'); opt.value = style.id; opt.textContent = `${speaker.name}(${style.name})`; if (style.id === cfg.vvSpeaker) opt.selected = true; sel.appendChild(opt); }); }); showToast(`話者 ${data.length}名 を取得しました`); } catch(e) { showToast('VOICEVOXサーバーに接続できません'); } }); // ============================================================ // チャット // ============================================================ const history = []; let isWaiting = false; const INIT_QR = ['事業内容', 'スクール情報', 'セミナー情報', 'お問い合わせ', 'アクセス']; function showQR(items) { qrArea.innerHTML = ''; items.forEach(label => { const btn = document.createElement('button'); btn.className = 'qr-btn'; btn.textContent = label; btn.onclick = () => { userInput.value = label; send(); }; qrArea.appendChild(btn); }); } function addMsg(text, role) { const row = document.createElement('div'); row.className = `msg-row ${role}`; const bub = document.createElement('div'); bub.className = 'bubble'; bub.textContent = text; row.appendChild(bub); chatWrap.appendChild(row); chatWrap.scrollTop = chatWrap.scrollHeight; return bub; } let typingEl = null; function showTyping() { typingEl = document.createElement('div'); typingEl.className = 'msg-row sora'; const bub = document.createElement('div'); bub.className = 'bubble'; bub.innerHTML = '
'; typingEl.appendChild(bub); chatWrap.appendChild(typingEl); chatWrap.scrollTop = chatWrap.scrollHeight; } function hideTyping() { typingEl?.remove(); typingEl = null; } async function send() { const text = userInput.value.trim(); if (!text || isWaiting) return; qrArea.innerHTML = ''; addMsg(text, 'user'); userInput.value = ''; userInput.style.height = 'auto'; history.push({ role: 'user', content: text }); isWaiting = true; sendBtn.disabled = true; showTyping(); try { const res = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: history.slice(-12) }), }); const data = await res.json(); hideTyping(); const reply = data.content?.[0]?.text || 'すみません、うまく受け取れませんでした。'; addMsg(reply, 'sora'); history.push({ role: 'assistant', content: reply }); speak(reply, () => {}); showQR(['他のことを聞く', 'サービス一覧', 'お問い合わせする']); } catch(e) { hideTyping(); addMsg('通信エラーが発生しました。しばらくしてからお試しください。', 'sora'); } isWaiting = false; sendBtn.disabled = false; } function greet() { const greeting = 'こんにちは!\n私はSORA、有限会社アライブの総合受付AIです✨\n\nアライブのサービス・スクール・セミナー情報など、お気軽にご質問ください😊'; setTimeout(() => { addMsg(greeting, 'sora'); history.push({ role: 'assistant', content: greeting }); speak(greeting, () => {}); showQR(INIT_QR); }, 300); } // ---- スプラッシュ ---- startBtn.addEventListener('click', () => { splash.classList.add('hidden'); appEl.style.display = 'flex'; appEl.style.flexDirection = 'column'; setTimeout(() => { splash.style.display = 'none'; }, 700); greet(); }); // ---- 入力 ---- sendBtn.addEventListener('click', send); userInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); userInput.addEventListener('input', () => { userInput.style.height = 'auto'; userInput.style.height = Math.min(userInput.scrollHeight, 110) + 'px'; }); // ---- 音声入力 ---- let recognition = null; if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; recognition = new SR(); recognition.lang = 'ja-JP'; recognition.continuous = false; recognition.interimResults = false; recognition.onresult = e => { userInput.value = e.results[0][0].transcript; micBtn.classList.remove('listening'); }; recognition.onend = () => micBtn.classList.remove('listening'); recognition.onerror = () => micBtn.classList.remove('listening'); micBtn.addEventListener('click', () => { if (micBtn.classList.contains('listening')) { recognition.stop(); } else { if (currentUtterance) window.speechSynthesis?.cancel(); recognition.start(); micBtn.classList.add('listening'); } }); } else { micBtn.style.display = 'none'; } // ---- 初期化 ---- loadCfg();