Spaces:
Runtime error
Runtime error
| <html lang="th"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>1157 Voicebot Demo</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| :root { | |
| color-scheme: light dark; | |
| --primary: #cc0067; | |
| --bg: #0e0f19; | |
| --card: #21243b; | |
| --text: #f6f7fb; | |
| --accent: #ff7eb6; | |
| } | |
| body { | |
| margin: 0; | |
| font-family: "Segoe UI", sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| display: flex; | |
| justify-content: center; | |
| min-height: 100vh; | |
| } | |
| #app { | |
| width: min(960px, 90vw); | |
| padding: 32px 20px 60px; | |
| } | |
| h1 { | |
| text-align: center; | |
| margin-bottom: 12px; | |
| color: var(--accent); | |
| text-shadow: 0 0 8px rgba(255, 126, 182, 0.6); | |
| } | |
| .controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| justify-content: center; | |
| margin-bottom: 20px; | |
| flex-wrap: wrap; | |
| } | |
| button { | |
| background: var(--primary); | |
| color: #fff; | |
| border: none; | |
| padding: 12px 24px; | |
| font-size: 1rem; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| button[disabled] { | |
| opacity: 0.65; | |
| cursor: not-allowed; | |
| } | |
| button:hover:not([disabled]) { | |
| transform: translateY(-1px); | |
| box-shadow: 0 8px 18px rgba(204, 0, 103, 0.35); | |
| } | |
| #status { | |
| text-align: center; | |
| min-height: 24px; | |
| font-size: 0.95rem; | |
| } | |
| #conversation { | |
| background: var(--card); | |
| border-radius: 18px; | |
| padding: 16px; | |
| max-height: 60vh; | |
| overflow-y: auto; | |
| box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); | |
| } | |
| .turn { | |
| margin-bottom: 14px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .turn.user .bubble { | |
| background: rgba(255, 255, 255, 0.08); | |
| align-self: flex-end; | |
| } | |
| .turn.assistant .bubble { | |
| background: rgba(255, 126, 182, 0.18); | |
| border: 1px solid rgba(255, 126, 182, 0.4); | |
| align-self: flex-start; | |
| } | |
| .bubble { | |
| padding: 10px 14px; | |
| border-radius: 14px; | |
| max-width: 75%; | |
| line-height: 1.45; | |
| white-space: pre-wrap; | |
| } | |
| .role { | |
| font-size: 0.75rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| opacity: 0.65; | |
| } | |
| @media (max-width: 600px) { | |
| .bubble { | |
| max-width: 100%; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <h1>1157 Voicebot Demo</h1> | |
| <div class="controls"> | |
| <button id="start-btn">เริ่มต้นสนทนา</button> | |
| <span id="status"></span> | |
| </div> | |
| <div id="conversation"></div> | |
| </div> | |
| <script> | |
| const startBtn = document.getElementById("start-btn"); | |
| const statusEl = document.getElementById("status"); | |
| const convoEl = document.getElementById("conversation"); | |
| let audioContext = null; | |
| let processor = null; | |
| let muteNode = null; | |
| let sourceNode = null; | |
| let mediaStream = null; | |
| let ws = null; | |
| let isStreaming = false; | |
| let conversation = []; | |
| function updateStatus(message, isError = false) { | |
| statusEl.textContent = message ?? ""; | |
| statusEl.style.color = isError ? "#ff8080" : ""; | |
| } | |
| function renderConversation() { | |
| convoEl.innerHTML = ""; | |
| conversation.forEach((turn) => { | |
| const turnEl = document.createElement("div"); | |
| turnEl.className = `turn ${turn.role}`; | |
| const roleEl = document.createElement("div"); | |
| roleEl.className = "role"; | |
| roleEl.textContent = turn.role === "user" ? "ท่าน" : "เจ้าหน้าที่"; | |
| const bubbleEl = document.createElement("div"); | |
| bubbleEl.className = "bubble"; | |
| bubbleEl.textContent = turn.content; | |
| turnEl.appendChild(roleEl); | |
| turnEl.appendChild(bubbleEl); | |
| convoEl.appendChild(turnEl); | |
| }); | |
| convoEl.scrollTop = convoEl.scrollHeight; | |
| } | |
| function floatTo16BitPCM(float32Array) { | |
| const buffer = new ArrayBuffer(float32Array.length * 2); | |
| const view = new DataView(buffer); | |
| let offset = 0; | |
| for (let i = 0; i < float32Array.length; i++, offset += 2) { | |
| let s = Math.max(-1, Math.min(1, float32Array[i])); | |
| view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); | |
| } | |
| return buffer; | |
| } | |
| function base64ToArrayBuffer(base64) { | |
| const binary = atob(base64); | |
| const len = binary.length; | |
| const bytes = new Uint8Array(len); | |
| for (let i = 0; i < len; i++) { | |
| bytes[i] = binary.charCodeAt(i); | |
| } | |
| return bytes.buffer; | |
| } | |
| function pcm16ToWav(int16Array, sampleRate) { | |
| const buffer = new ArrayBuffer(44 + int16Array.length * 2); | |
| const view = new DataView(buffer); | |
| function writeString(view, offset, string) { | |
| for (let i = 0; i < string.length; i++) { | |
| view.setUint8(offset + i, string.charCodeAt(i)); | |
| } | |
| } | |
| const bytesPerSample = 2; | |
| const blockAlign = bytesPerSample * 1; | |
| writeString(view, 0, "RIFF"); | |
| view.setUint32(4, 36 + int16Array.length * bytesPerSample, true); | |
| writeString(view, 8, "WAVE"); | |
| writeString(view, 12, "fmt "); | |
| view.setUint32(16, 16, true); | |
| view.setUint16(20, 1, true); | |
| view.setUint16(22, 1, true); | |
| view.setUint32(24, sampleRate, true); | |
| view.setUint32(28, sampleRate * blockAlign, true); | |
| view.setUint16(32, blockAlign, true); | |
| view.setUint16(34, bytesPerSample * 8, true); | |
| writeString(view, 36, "data"); | |
| view.setUint32(40, int16Array.length * bytesPerSample, true); | |
| for (let i = 0; i < int16Array.length; i++) { | |
| view.setInt16(44 + i * 2, int16Array[i], true); | |
| } | |
| return buffer; | |
| } | |
| async function playPcmAudio(int16Array, sampleRate) { | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| const wavBuffer = pcm16ToWav(int16Array, sampleRate); | |
| const audioBuffer = await audioContext.decodeAudioData(wavBuffer); | |
| const trackSource = audioContext.createBufferSource(); | |
| trackSource.buffer = audioBuffer; | |
| trackSource.connect(audioContext.destination); | |
| trackSource.start(); | |
| } | |
| async function setupAudio() { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); | |
| sourceNode = audioContext.createMediaStreamSource(mediaStream); | |
| processor = audioContext.createScriptProcessor(4096, 1, 1); | |
| processor.onaudioprocess = (event) => { | |
| if (!isStreaming || ws?.readyState !== WebSocket.OPEN) { | |
| return; | |
| } | |
| const input = event.inputBuffer.getChannelData(0); | |
| const pcmBuffer = floatTo16BitPCM(input); | |
| ws.send(pcmBuffer); | |
| }; | |
| sourceNode.connect(processor); | |
| muteNode = audioContext.createGain(); | |
| muteNode.gain.value = 0; | |
| processor.connect(muteNode); | |
| muteNode.connect(audioContext.destination); | |
| } | |
| async function startConversation() { | |
| if (isStreaming) { | |
| return; | |
| } | |
| try { | |
| updateStatus("กำลังเปิดไมโครโฟน..."); | |
| await setupAudio(); | |
| updateStatus("เชื่อมต่อเซิร์ฟเวอร์..."); | |
| const protocol = window.location.protocol === "https:" ? "wss" : "ws"; | |
| ws = new WebSocket(`${protocol}://${window.location.host}/ws/voice`); | |
| ws.binaryType = "arraybuffer"; | |
| ws.onopen = () => { | |
| isStreaming = true; | |
| updateStatus("เริ่มสนทนาแล้ว กำลังรับฟังเสียงของท่าน..."); | |
| const meta = { type: "config", sampleRate: audioContext.sampleRate }; | |
| ws.send(JSON.stringify(meta)); | |
| }; | |
| ws.onmessage = async (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| if (data.type === "status") { | |
| updateStatus(data.message, data.level === "error"); | |
| } else if (data.type === "transcript") { | |
| conversation.push({ role: "user", content: data.text }); | |
| renderConversation(); | |
| } else if (data.type === "reply") { | |
| conversation.push({ role: "assistant", content: data.text }); | |
| renderConversation(); | |
| if (data.audio_base64) { | |
| const audioBuffer = base64ToArrayBuffer(data.audio_base64); | |
| const int16View = new Int16Array(audioBuffer); | |
| await playPcmAudio(int16View, data.audio_sample_rate); | |
| } | |
| } else if (data.type === "info") { | |
| updateStatus(data.message); | |
| } | |
| } catch (err) { | |
| console.error("Failed to parse incoming message", err); | |
| } | |
| }; | |
| ws.onclose = () => { | |
| updateStatus("การเชื่อมต่อสิ้นสุดแล้ว"); | |
| isStreaming = false; | |
| startBtn.disabled = false; | |
| if (processor) { | |
| processor.disconnect(); | |
| processor = null; | |
| } | |
| if (muteNode) { | |
| muteNode.disconnect(); | |
| muteNode = null; | |
| } | |
| if (sourceNode) { | |
| sourceNode.disconnect(); | |
| sourceNode = null; | |
| } | |
| if (mediaStream) { | |
| mediaStream.getTracks().forEach((track) => track.stop()); | |
| mediaStream = null; | |
| } | |
| }; | |
| ws.onerror = (err) => { | |
| console.error("WebSocket error", err); | |
| updateStatus("เกิดข้อผิดพลาดของ WebSocket", true); | |
| }; | |
| startBtn.disabled = true; | |
| } catch (err) { | |
| console.error(err); | |
| updateStatus("ไม่สามารถเปิดไมโครโฟนหรือเชื่อมต่อได้", true); | |
| if (mediaStream) { | |
| mediaStream.getTracks().forEach((track) => track.stop()); | |
| mediaStream = null; | |
| } | |
| } | |
| } | |
| startBtn.addEventListener("click", () => { | |
| startConversation(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |