1577-2 / frontend /index.html
jts-ai-team's picture
Upload 32 files
ed216da verified
Raw
History Blame
11 kB
<!DOCTYPE html>
<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>