- Published on
- เผยแพร่เมื่อ(แก้ไขเมื่อ 2 วันที่ผ่านมา)
สร้าง Text-to-Speech ด้วย Edge TTS
ในบทความนี้เราจะมาเรียนรู้วิธีการใช้งาน Edge TTS Selfhost UI ซึ่งเป็น Web Application สำหรับแปลงข้อความเป็นเสียง (Text-to-Speech) ที่มี UI ใช้งานง่าย และรองรับภาษาไทย สามารถ deploy ผ่าน Docker ได้ทันที
Edge TTS Selfhost UI คืออะไร?
Edge TTS Selfhost UI เป็น Web Application ที่พัฒนาด้วย Python และ FastAPI ใช้ Microsoft Edge Text-to-Speech API ในการแปลงข้อความเป็นเสียง มีจุดเด่นดังนี้:
- Web Interface - มี UI ที่ออกแบบมาด้วย Glass-morphism design และ Tailwind CSS
- ฟรี - ไม่ต้องจ่ายค่าใช้จ่ายในการเรียกใช้ API
- คุณภาพเสียงสูง - ใช้เสียงจาก Microsoft Edge ที่มีความชัดเจนและเป็นธรรมชาติ
- รองรับหลายภาษา - รวมถึงภาษาไทย (Premwadee, Achara, etc.)
- หลายเสียงพูด - เลือกได้จากหลายๆ เสียงที่มีให้
- Real-time Audio Streaming - รองรับการสตรีมเสียงแบบ real-time
- Audio Visualization - มี waveform visualization ขณะเล่นเสียง
เตรียมสิ่งที่ต้องการ
ก่อนเริ่มต้น ตรวจสอบให้แน่ใจว่าคุณมี:
- Docker ติดตั้งแล้วบนเครื่อง
- Python 3.7+ (ถ้าต้องการทดสอบแบบ local)
- พื้นที่ว่าง ประมาณ 500MB สำหรับ Docker image
โครงสร้างของ Edge TTS Selfhost UI
ก่อนจะเริ่มติดตั้ง มาดูโครงสร้างของโปรเจคกันก่อน:
edge-tts/
├── Dockerfile # ไฟล์สำหรับสร้าง Docker image
├── docker-compose.yml # ไฟล์สำหรับจัดการ containers
├── requirements.txt # Python dependencies
├── server.py # FastAPI backend server
└── static/ # Frontend files
├── index.html # Main UI page
├── app.js # JavaScript functionality
└── style.css # Styling
การสร้าง Dockerfile
Dockerfile ของ Edge TTS Selfhost UI นั้นมีความเรียบง่ายและมีประสิทธิภาพ:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
สร้าง requirements.txt ตามจริงของโปรเจค:
fastapi
uvicorn[standard]
jinja2
edge-tts
การสร้าง FastAPI Server
server.py เป็น backend ของ Edge TTS Selfhost UI ที่ให้บริการ API สำหรับแปลงข้อความเป็นเสียง:
import traceback
from io import BytesIO
import edge_tts
from fastapi import FastAPI, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI(title="Edge TTS Selfhost UI")
DEFAULT_VOICE = "th-TH-AcharaNeural"
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="static")
def fix_param(value: str) -> str:
if not value.startswith(("+", "-")):
return f"+{value}"
return value
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/voices")
async def list_voices():
voices = await edge_tts.list_voices()
return JSONResponse(voices)
@app.get("/tts")
async def tts(
text: str = Query(..., max_length=5000),
voice: str = DEFAULT_VOICE,
rate: str = "+0%",
volume: str = "+0%",
):
try:
if not text.strip():
return JSONResponse({"error": "text is empty"}, status_code=400)
rate = fix_param(rate)
volume = fix_param(volume)
communicate = edge_tts.Communicate(
text=text,
voice=voice,
rate=rate,
volume=volume,
)
audio_buffer = BytesIO()
async for chunk in communicate.stream():
if chunk["type"] == "audio":
audio_buffer.write(chunk["data"])
audio_buffer.seek(0)
return StreamingResponse(audio_buffer, media_type="audio/mpeg")
except Exception as e:
traceback.print_exc()
return JSONResponse({"error": str(e)}, status_code=500)
การใช้งาน Docker Compose
สร้าง docker-compose.yml สำหรับความสะดวกในการจัดการ:
services:
edge-tts:
build: .
container_name: edge-tts
ports:
- "8000:8000"
restart: always
environment:
- TZ=Asia/Bangkok
volumes:
- ./cache:/app/cache
การติดตั้งและใช้งาน
วิธีที่ 1: ใช้งานผ่าน Docker Compose (แนะนำ)
# 1. Clone repository
git clone https://github.com/KONGKIAT888/edge-tts.git
cd edge-tts
# 2. รันด้วย Docker Compose
docker-compose up -d
# 3. ตรวจสอบสถานะ
docker-compose ps
วิธีที่ 2: ใช้งานผ่าน Docker โดยตรง
# 1. Clone repository
git clone https://github.com/KONGKIAT888/edge-tts.git
cd edge-tts
# 2. Build Docker image
docker build -t edge-tts-ui .
# 3. Run container
docker run -d \
--name edge-tts \
-p 8000:8000 \
--restart always \
edge-tts-ui
# 4. ตรวจสอบสถานะ
docker ps
docker logs edge-tts
การใช้งาน Edge TTS Selfhost UI
หลังจากติดตั้งเรียบร้อยแล้ว สามารถเข้าใช้งานได้ที่:
- Main UI:
http://localhost:8000 - API Documentation:
http://localhost:8000/docs
คุณสมบัติของ UI
- ช่องกรอกข้อความ - พร้อมตัวนับจำนวนตัวอักษร
- เลือกเสียง - รองรับหลายภาษา รวมถึงภาษาไทย
- ปุ่ม Speak - ฟังเสียงแบบ real-time
- ปุ่ม Download - ดาวน์โหลดไฟล์เสียง
- Audio Player - พร้อม waveform visualization
- Voice Samples - ทดลองฟังเสียงต่างๆ ได้
เสียงภาษาไทยที่รองรับ
- th-TH-PremwadeeNeural (เสียงหญิง)
- th-TH-AcharaNeural (เสียงหญิง)
- th-TH-NiwatNeural (เสียงชาย)
การใช้งาน API (สำหรับนักพัฒนา)
ดูรายการเสียงทั้งหมด:
curl http://localhost:8000/voices
แปลงข้อความเป็นเสียงผ่าน API:
curl "http://localhost:8000/tts?text=สวัสดีครับ&voice=th-TH-PremwadeeNeural" \
--output speech.mp3
คุณสมบัติของ Edge TTS Selfhost UI
Frontend
ผมได้ พัฒนา Frontend มาให้พร้อมใช้งานแล้ว ไม่ต้องมานั่งเขียน HTML/CSS/JavaScript เอง
UI ที่ทันสมัย
Edge TTS Selfhost UI มีการออกแบบด้วย Glass-morphism design ที่ผมสร้างขึ้นมาเพื่อให้ดูทันสมัย:
- Blue Gradient Theme - ธีมสีฟ้าที่ดูสบายตา
- Responsive Design - รองรับการใช้งานบนทุกขนาดหน้าจอ
- Smooth Animations - มี animation ที่นุ่มนวล
- Thai Language Interface - รองรับภาษาไทยเต็มรูปแบบ
ฟังก์ชันการทำงานที่ครบครัน (พร้อมใช้งาน)
- Text Input พร้อมตัวนับตัวอักษร
- Voice Selection เลือกเสียงได้จากหลายภาษา
- Real-time TTS แปลงข้อความเป็นเสียงทันที
- Audio Player พร้อม waveform visualization
- Download Feature ดาวน์โหลดไฟล์เสียง MP3
- Voice Samples ทดลองฟังเสียงก่อนใช้งานจริง
โครงสร้าง Frontend
static/
├── app.js # JavaScript functionality=
├── index.html # HTML หน้าหลัก
└── style.css # Styling
สร้าง app.js:
let currentAudioUrl = null;
let audioContext = null;
let analyser = null;
function clearText() {
document.getElementById('text').value = '';
document.getElementById('charCount').textContent = '0 ตัวอักษร';
}
function updateStatus(message, type = 'info') {
const status = document.getElementById('status');
const icons = {
'info': 'fa-info-circle',
'loading': 'fa-spinner fa-spin',
'success': 'fa-check-circle',
'error': 'fa-exclamation-circle'
};
status.innerHTML = `<i class="fas ${icons[type]} mr-1"></i>${message}`;
}
function getCountryFlag(locale) {
const flags = {
'en-US': '🇺🇸',
'en-GB': '🇬🇧',
'ja-JP': '🇯🇵',
'zh-CN': '🇨🇳',
'zh-TW': '🇹🇼',
'ko-KR': '🇰🇷',
'es-ES': '🇪🇸',
'fr-FR': '🇫🇷',
'de-DE': '🇩🇪',
'it-IT': '🇮🇹',
'pt-BR': '🇧🇷',
'ru-RU': '🇷🇺'
};
return flags[locale] || '🌍';
}
async function loadVoices() {
try {
updateStatus('กำลังโหลดเสียงพูด...', 'loading');
const res = await fetch("/voices");
const voices = await res.json();
const select = document.getElementById("voice");
select.innerHTML = '';
const voicesByLocale = {};
voices.forEach(v => {
if (!voicesByLocale[v.Locale]) {
voicesByLocale[v.Locale] = [];
}
voicesByLocale[v.Locale].push(v);
});
if (voicesByLocale['th-TH']) {
const thaiGroup = document.createElement('optgroup');
thaiGroup.label = '🇹🇭 ภาษาไทย';
voicesByLocale['th-TH'].forEach(v => {
const opt = document.createElement("option");
opt.value = v.ShortName;
opt.textContent = `${v.DisplayName || v.ShortName}`;
if (v.ShortName === 'KanyaNeural') opt.selected = true;
thaiGroup.appendChild(opt);
});
select.appendChild(thaiGroup);
delete voicesByLocale['th-TH'];
}
Object.keys(voicesByLocale).sort().forEach(locale => {
const group = document.createElement('optgroup');
group.label = `${locale} ${getCountryFlag(locale)}`;
voicesByLocale[locale].forEach(v => {
const opt = document.createElement("option");
opt.value = v.ShortName;
opt.textContent = `${v.DisplayName || v.ShortName}`;
group.appendChild(opt);
});
select.appendChild(group);
});
loadVoiceSamples(voices);
updateStatus('พร้อมสำหรับการใช้งาน', 'success');
} catch (error) {
updateStatus('เกิดข้อผิดพลาดในการโหลดเสียง', 'error');
}
}
function loadVoiceSamples(voices) {
const samplesContainer = document.getElementById('voiceSamples');
samplesContainer.innerHTML = '';
const sampleVoices = voices.filter(v =>
v.Locale.startsWith('th-') ||
['en-US-JennyNeural', 'ja-JP-NanamiNeural', 'zh-CN-XiaoxiaoNeural'].includes(v.ShortName)
).slice(0, 8);
sampleVoices.forEach(voice => {
const card = document.createElement('div');
card.className = 'voice-card bg-white/80 rounded-lg p-3 border border-blue-200 cursor-pointer transition-all';
card.innerHTML = `
<div class="text-center">
<div class="w-10 h-10 mx-auto mb-2 bg-blue-100 rounded-full flex items-center justify-center">
<i class="fas fa-user text-blue-600 text-sm"></i>
</div>
<div class="text-gray-700 text-xs font-medium truncate">${voice.DisplayName || voice.ShortName}</div>
<div class="text-gray-500 text-xs">${voice.Locale}</div>
</div>
`;
card.onclick = () => {
const voiceSamplesSection = document.getElementById('voiceSamples').closest('.liquid-glass');
voiceSamplesSection.classList.add('active');
document.getElementById('voice').value = voice.ShortName;
testVoice();
setTimeout(() => {
voiceSamplesSection.classList.remove('active');
}, 1000);
};
samplesContainer.appendChild(card);
});
}
async function speak() {
const text = document.getElementById("text").value.trim();
const voice = document.getElementById("voice").value;
const btn = document.getElementById('speakBtn');
if (!text) {
updateStatus('กรุณาใส่ข้อความ', 'error');
return;
}
if (!voice) {
updateStatus('กรุณาเลือกเสียงพูด', 'error');
return;
}
const mainContainer = document.querySelector('.liquid-glass.rounded-3xl');
mainContainer.classList.add('active');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>กำลังสร้าง...';
updateStatus('กำลังสร้างเสียง...', 'loading');
try {
const url = `/tts?text=${encodeURIComponent(text)}&voice=${encodeURIComponent(voice)}`;
const audio = document.getElementById("player");
audio.src = url;
currentAudioUrl = url;
audio.play();
updateStatus('กำลังเล่นเสียง...', 'success');
showVisualizer();
audio.onended = () => {
hideVisualizer();
updateStatus('เล่นเสียงเสร็จสิ้น', 'info');
mainContainer.classList.remove('active');
};
} catch (error) {
updateStatus('เกิดข้อผิดพลาด', 'error');
mainContainer.classList.remove('active');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-play mr-2"></i>สร้างเสียง';
}
}
async function testVoice() {
const voice = document.getElementById("voice").value;
if (!voice) {
updateStatus('กรุณาเลือกเสียงพูด', 'error');
return;
}
const sampleTexts = {
'th-': 'สวัสดีครับ นี่คือเสียงตัวอย่างของผม',
'en-': 'Hello, this is a sample of my voice',
'ja-': 'こんにちは、これは私の声のサンプルです',
'zh-': '你好,这是我的声音示例',
'ko-': '안녕하세요, 이것은 제 목소리 샘플입니다',
'default': 'Hello, this is a voice sample'
};
let sampleText = sampleTexts['default'];
for (const [prefix, text] of Object.entries(sampleTexts)) {
if (voice.includes(prefix)) {
sampleText = text;
break;
}
}
document.getElementById('text').value = sampleText;
document.getElementById('charCount').textContent = `${sampleText.length} ตัวอักษร`;
await speak();
}
async function downloadAudio() {
if (!currentAudioUrl) {
updateStatus('กรุณาสร้างเสียงก่อน', 'error');
return;
}
const downloadBtn = document.getElementById('downloadBtn');
const downloadSection = downloadBtn.closest('.liquid-glass');
downloadSection.classList.add('active');
try {
const response = await fetch(currentAudioUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `tts-${Date.now()}.mp3`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
updateStatus('ดาวน์โหลดสำเร็จ', 'success');
} catch (error) {
updateStatus('ดาวน์โหลดล้มเหลว', 'error');
} finally {
setTimeout(() => {
downloadSection.classList.remove('active');
}, 500);
}
}
function copyLink() {
if (!currentAudioUrl) {
updateStatus('กรุณาสร้างเสียงก่อน', 'error');
return;
}
const copyBtn = document.getElementById('copyBtn');
const copySection = copyBtn.closest('.liquid-glass');
copySection.classList.add('active');
navigator.clipboard.writeText(window.location.origin + currentAudioUrl).then(() => {
updateStatus('คัดลอกลิงก์แล้ว', 'success');
}).catch(() => {
updateStatus('คัดลอกลิงก์ล้มเหลว', 'error');
}).finally(() => {
setTimeout(() => {
copySection.classList.remove('active');
}, 500);
});
}
function showVisualizer() {
const visualizer = document.getElementById('visualizer');
visualizer.style.opacity = '1';
const bars = visualizer.querySelectorAll('.wave-animation');
bars.forEach((bar, index) => {
const randomHeight = 20 + Math.random() * 80;
const randomDelay = Math.random() * 200;
bar.style.height = `${randomHeight}%`;
bar.style.animationDelay = `${randomDelay}ms`;
});
}
function hideVisualizer() {
const visualizer = document.getElementById('visualizer');
visualizer.style.opacity = '0';
const bars = visualizer.querySelectorAll('.wave-animation');
bars.forEach((bar, index) => {
const originalHeights = [15, 25, 35, 45, 60, 75, 85, 95, 100, 98, 100, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10];
const originalDelays = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900];
bar.style.height = `${originalHeights[index]}%`;
bar.style.animationDelay = `${originalDelays[index]}ms`;
});
}
document.addEventListener('DOMContentLoaded', function() {
const textInput = document.getElementById('text');
const voiceSelect = document.getElementById('voice');
textInput.addEventListener('focus', () => {
const textSection = textInput.closest('.liquid-glass');
textSection.classList.add('active');
});
textInput.addEventListener('blur', () => {
const textSection = textInput.closest('.liquid-glass');
textSection.classList.remove('active');
});
voiceSelect.addEventListener('change', () => {
const voiceSection = voiceSelect.closest('.liquid-glass');
voiceSection.classList.add('active');
setTimeout(() => {
voiceSection.classList.remove('active');
}, 500);
});
loadVoices();
const secondaryButtons = document.querySelectorAll('.ios-secondary');
secondaryButtons.forEach(button => {
button.addEventListener('mousemove', (e) => {
const rect = button.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
button.style.setProperty('--x', `${x}%`);
button.style.setProperty('--y', `${y}%`);
});
});
});
document.getElementById('text').addEventListener('input', function() {
const charCount = this.value.length;
document.getElementById('charCount').textContent = `${charCount} ตัวอักษร`;
});
สร้าง style.css:
@import url('https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Kanit', sans-serif;
scroll-behavior: smooth;
}
* {
-webkit-tap-highlight-color: transparent;
}
.ios-button {
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
position: relative;
overflow: hidden;
}
.ios-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.ios-button:hover::before {
width: 300px;
height: 300px;
}
.ios-button:active {
transform: scale(0.96);
}
.ios-primary {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 25%, #2563eb 50%, #3b82f6 75%, #60a5fa 100%);
box-shadow: 0 2px 8px rgba(96, 165, 250, 0.3);
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
position: relative;
overflow: hidden;
color: #ffffff;
}
.ios-primary::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, #fbbf24 0%, #fb923c 25%, #f87171 50%, #fb7185 75%, #fbbf24 100%);
background-size: 400% 400%;
border-radius: inherit;
z-index: -1;
opacity: 0;
transition: opacity 0.3s;
animation: gradient-shift 3s ease infinite;
}
.ios-primary::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 25%, #2563eb 50%, #3b82f6 75%, #60a5fa 100%);
border-radius: inherit;
z-index: -1;
}
.ios-primary:hover::before {
opacity: 1;
}
.ios-primary:hover {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 25%, #1d4ed8 50%, #2563eb 75%, #3b82f6 100%);
box-shadow:
0 6px 16px rgba(59, 130, 246, 0.4),
0 2px 8px rgba(37, 99, 235, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
transform: translateY(-3px);
color: #ffffff;
}
.ios-primary:hover::after {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 25%, #1d4ed8 50%, #2563eb 75%, #3b82f6 100%);
}
@keyframes gradient-shift {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
.ios-secondary {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, rgba(248, 250, 252, 0.8) 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(148, 163, 184, 0.2);
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
position: relative;
overflow: hidden;
}
.ios-secondary::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(147, 197, 253, 0.3) 50%,
transparent 100%
);
transition: all 0.6s cubic-bezier(0.23, 1, 0.32, 1);
}
.ios-secondary::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
circle at var(--x, 50%) var(--y, 50%),
rgba(59, 130, 246, 0.1) 0%,
transparent 50%
);
opacity: 0;
transition: opacity 0.3s;
}
.ios-secondary:hover::before {
left: 100%;
}
.ios-secondary:hover::after {
opacity: 1;
}
.ios-secondary:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(241, 245, 249, 0.95) 100%);
border-color: rgba(59, 130, 246, 0.4);
transform: translateY(-3px);
box-shadow:
0 15px 35px rgba(59, 130, 246, 0.15),
0 8px 18px rgba(147, 197, 253, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.gradient-bg {
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 25%, #7dd3fc 50%, #bae6fd 75%, #e0f2fe 100%);
}
@keyframes wave {
0%, 100% {
transform: scaleY(1);
opacity: 0.85;
filter: brightness(1);
}
25% {
transform: scaleY(1.2);
opacity: 0.95;
filter: brightness(1.1);
}
50% {
transform: scaleY(1.4);
opacity: 1;
filter: brightness(1.2);
}
75% {
transform: scaleY(1.1);
opacity: 0.9;
filter: brightness(1.05);
}
}
@keyframes wave-center {
0%, 100% {
transform: scaleY(1);
opacity: 0.9;
filter: brightness(1.1);
}
20% {
transform: scaleY(1.15);
opacity: 0.95;
filter: brightness(1.15);
}
40% {
transform: scaleY(1.25);
opacity: 1;
filter: brightness(1.25);
}
60% {
transform: scaleY(1.35);
opacity: 1;
filter: brightness(1.3);
}
80% {
transform: scaleY(1.1);
opacity: 0.95;
filter: brightness(1.1);
}
}
@keyframes pulse-shadow {
0%, 100% {}
50% {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.8);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 10px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.6);
}
}
.wave-animation {
animation: wave 1s ease-in-out infinite;
transition: all 0.3s ease;
transform-origin: bottom;
}
.wave-center {
animation: wave-center 1s ease-in-out infinite;
transition: all 0.3s ease;
transform-origin: bottom;
}
.wave-animation:hover {
animation-duration: 0.6s;
transform: scaleY(1.15);
filter: brightness(1.3);
}
.wave-center:hover {
animation-duration: 0.5s;
transform: scaleY(1.25);
filter: brightness(1.4);
}
#visualizer:hover .wave-animation,
#visualizer:hover .wave-center {
animation-play-state: paused;
transform: scaleY(1.2);
filter: brightness(1.5);
}
.enhanced-glow {
animation: pulse-shadow 1.5s ease-in-out infinite;
}
#visualizer .wave-animation,
#visualizer .wave-center {
will-change: transform, opacity, filter;
}
.liquid-glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px) saturate(1.8) contrast(1.1);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8),
inset 0 -1px 0 rgba(255, 255, 255, 0.5);
position: relative;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.liquid-glass::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
135deg,
transparent 0%,
rgba(147, 51, 234, 0.08) 30%,
rgba(59, 130, 246, 0.06) 50%,
rgba(147, 51, 234, 0.08) 70%,
transparent 100%
);
opacity: 0.7;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transform: rotate(45deg) scale(1.5);
}
.liquid-glass::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
circle at 30% 30%,
rgba(255, 255, 255, 0.12) 0%,
transparent 50%
);
opacity: 0.8;
}
.liquid-glass:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.8);
border-color: rgba(59, 130, 246, 0.2);
box-shadow:
0 10px 30px rgba(59, 130, 246, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.liquid-glass:hover::after {
opacity: 0.95;
background: radial-gradient(
circle at 35% 35%,
rgba(255, 255, 255, 0.15) 0%,
transparent 55%
);
}
.liquid-glass.active {
transform: translateY(-4px);
box-shadow:
0 16px 60px rgba(59, 130, 246, 0.2),
0 8px 32px rgba(147, 51, 234, 0.12),
inset 0 2px 0 rgba(255, 255, 255, 0.9),
inset 0 -2px 0 rgba(255, 255, 255, 0.7);
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.85);
}
.liquid-glass.active::before {
opacity: 1;
transform: rotate(45deg) scale(2) translateX(15px) translateY(-15px);
animation: liquidActive 3s ease-in-out infinite;
}
.liquid-glass.active::after {
opacity: 1;
animation: shineActive 2s ease-in-out infinite;
}
.liquid-glass.active .liquid-wave {
transform: translateY(-5px) scaleY(1.12);
opacity: 1;
}
@keyframes liquidActive {
0%, 100% {
transform: rotate(45deg) scale(2) translateX(15px) translateY(-15px);
}
50% {
transform: rotate(45deg) scale(2.2) translateX(20px) translateY(-10px);
}
}
@keyframes shineActive {
0%, 100% {
opacity: 0.9;
}
50% {
opacity: 1;
}
}
.liquid-wave {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 35%;
background: linear-gradient(
180deg,
transparent 0%,
rgba(147, 51, 234, 0.05) 30%,
rgba(59, 130, 246, 0.12) 60%,
rgba(147, 51, 234, 0.05) 100%
);
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
transform: translateY(0) scaleY(1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0.6;
}
.liquid-button {
position: relative;
overflow: hidden;
}
.liquid-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.liquid-button:hover::before {
width: 300px;
height: 300px;
}
.voice-card {
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
position: relative;
overflow: hidden;
}
.voice-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
transparent 0%,
rgba(59, 130, 246, 0.05) 50%,
transparent 100%
);
opacity: 0;
transition: opacity 0.4s;
}
.voice-card::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(59, 130, 246, 0.1) 0%,
transparent 50%
);
opacity: 0;
transform: scale(0);
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
}
.voice-card:hover::before {
opacity: 1;
}
.voice-card:hover::after {
opacity: 1;
transform: scale(1);
}
.voice-card:hover {
background: rgba(255, 255, 255, 0.98);
border-color: rgba(59, 130, 246, 0.5);
transform: translateY(-4px);
box-shadow:
0 20px 40px rgba(59, 130, 246, 0.2),
0 10px 20px rgba(147, 197, 253, 0.15),
0 5px 10px rgba(59, 130, 246, 0.1);
}
.voice-card:active {
transform: translateY(-2px);
box-shadow:
0 12px 25px rgba(59, 130, 246, 0.15),
0 6px 12px rgba(147, 197, 253, 0.1);
}
select option {
background-color: white;
color: #1f2937;
}
textarea::placeholder {
color: #6b7280 !important;
}
.text-readable {
color: #374151 !important;
}
audio {
filter: brightness(0.8) contrast(1.2);
}
สร้าง index.html:
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Edge TTS Selfhost - Text to Speech</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="gradient-bg min-h-screen">
<div class="fixed inset-0 opacity-20">
<div class="absolute inset-0" style="background-image: url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%230ea5e9" fill-opacity="0.3"%3E%3Cpath d="M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E');"></div>
</div>
<header class="relative z-10 text-center py-8 px-4">
<div class="liquid-glass rounded-3xl p-8 max-w-2xl mx-auto">
<div class="liquid-wave"></div>
<div class="relative z-10">
<div class="flex items-center justify-center gap-4 mb-6">
<div class="w-16 h-16 bg-gradient-to-br from-blue-400 to-blue-600 rounded-2xl flex items-center justify-center">
<i class="fas fa-microphone-alt text-white text-2xl"></i>
</div>
<h1 class="text-4xl md:text-6xl font-bold bg-gradient-to-r from-blue-400 via-blue-500 to-blue-600 bg-clip-text text-transparent">
Edge TTS
</h1>
</div>
<p class="text-gray-700 text-xl font-medium">แปลงข้อความเป็นเสียงด้วย AI คุณภาพสูง</p>
</div>
</div>
</header>
<main class="relative z-10 container mx-auto px-4 pb-12 max-w-6xl">
<div class="grid md:grid-cols-2 gap-8">
<section class="liquid-glass rounded-2xl p-6">
<div class="liquid-wave"></div>
<div class="relative z-10">
<h2 class="text-2xl font-semibold text-gray-700 mb-6 flex items-center gap-2">
<i class="fas fa-keyboard text-blue-500"></i>
ข้อความของคุณ
</h2>
<div class="mb-4">
<label class="block text-gray-600 text-sm font-medium mb-2">พิมพ์หรือวางข้อความ</label>
<textarea
id="text"
class="w-full p-4 rounded-xl bg-white/70 border border-blue-200 text-gray-800 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all resize-none hover:bg-white/80 hover:border-blue-300"
rows="6"
placeholder="สวัสดีครับ ยินดีต้อนรับสู่ Edge TTS... 🎉"
></textarea>
<div class="flex justify-between items-center mt-2">
<span id="charCount" class="text-gray-500 text-sm">0 ตัวอักษร</span>
<button onclick="clearText()" class="text-gray-500 text-sm ios-button hover:text-blue-600 transition-all">
<i class="fas fa-eraser mr-1"></i>ล้างข้อความ
</button>
</div>
</div>
<div class="mb-6">
<label class="block text-gray-600 text-sm font-medium mb-2">เลือกเสียงพูด</label>
<div class="relative">
<select
id="voice"
class="w-full p-4 rounded-xl bg-white/70 border border-blue-200 text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-400 appearance-none cursor-pointer transition-all hover:bg-white/80 hover:border-blue-300"
>
<option value="">กำลังโหลด...</option>
</select>
<div class="absolute right-4 top-1/2 transform -translate-y-1/2 pointer-events-none">
<i class="fas fa-chevron-down text-gray-500"></i>
</div>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button
id="speakBtn"
onclick="speak()"
class="flex-1 py-3 px-6 ios-primary rounded-2xl text-white font-semibold flex items-center justify-center gap-2 relative overflow-hidden liquid-button ios-button"
>
<i class="fas fa-play"></i>
<span>สร้างเสียง</span>
</button>
<button
onclick="downloadAudio()"
id="downloadBtn"
class="py-3 px-6 ios-secondary rounded-xl text-gray-700 font-semibold flex items-center justify-center gap-2 ios-button"
>
<i class="fas fa-download"></i>
<span>ดาวน์โหลด</span>
</button>
</div>
</div>
</section>
<section class="liquid-glass rounded-2xl p-6">
<div class="liquid-wave"></div>
<div class="relative z-10">
<h2 class="text-2xl font-semibold text-gray-700 mb-6 flex items-center gap-2">
<i class="fas fa-headphones text-blue-500"></i>
เครื่องเล่นเสียง
</h2>
<div id="visualizer" class="mt-20 mb-6 h-36 flex items-end justify-center gap-1 opacity-0 transition-opacity duration-500">
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 15%; animation-delay: 0ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 25%; animation-delay: 50ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 35%; animation-delay: 100ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 45%; animation-delay: 150ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 60%; animation-delay: 200ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 75%; animation-delay: 250ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 85%; animation-delay: 300ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 95%; animation-delay: 350ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 100%; animation-delay: 400ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 98%; animation-delay: 450ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 100%; animation-delay: 500ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 95%; animation-delay: 550ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 90%; animation-delay: 600ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 80%; animation-delay: 650ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 70%; animation-delay: 700ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 60%; animation-delay: 750ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 50%; animation-delay: 800ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 40%; animation-delay: 850ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 30%; animation-delay: 900ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 20%; animation-delay: 950ms;"></div>
<div class="w-4 bg-gradient-to-t from-sky-400 to-blue-500 rounded-full wave-animation" style="height: 10%; animation-delay: 1000ms;"></div>
</div>
<div id="status" class="mt-4 text-center text-gray-600 text-sm">
<i class="fas fa-info-circle mr-1"></i>
พร้อมสำหรับการใช้งาน
</div>
<div class="mt-6 bg-white/80 rounded-xl p-4 border border-blue-200">
<audio id="player" controls class="w-full"></audio>
</div>
<div class="mt-4 grid grid-cols-2 gap-3">
<button onclick="testVoice()" class="py-2 px-4 ios-secondary rounded-lg text-gray-600 text-sm ios-button">
<i class="fas fa-volume-up mr-1"></i>ทดสอบเสียง
</button>
<button onclick="copyLink()" id="copyBtn" class="py-2 px-4 ios-secondary rounded-lg text-gray-600 text-sm ios-button">
<i class="fas fa-link mr-1"></i>คัดลอกลิงก์
</button>
</div>
</div>
</section>
</div>
<section class="mt-8 liquid-glass rounded-2xl p-6">
<div class="liquid-wave"></div>
<div class="relative z-10">
<h2 class="text-2xl font-semibold text-gray-700 mb-6 flex items-center gap-2">
<i class="fas fa-microphone-lines text-blue-500"></i>
เสียงตัวอย่าง
</h2>
<div id="voiceSamples" class="grid grid-cols-2 md:grid-cols-4 gap-4">
</div>
</div>
</section>
</main>
<footer class="relative z-10 liquid-glass rounded-t-2xl text-center py-6">
<div class="liquid-wave"></div>
<div class="relative z-10 text-gray-600">
<p>สร้างด้วย ❤️ โดยใช้ Microsoft Edge TTS</p>
</div>
</footer>
<script src="/static/app.js" defer></script>
</body>
</html>
ข้อดีของการมี Frontend พร้อมใช้งาน:
- ✅ ไม่ต้องเขียนโค้ด frontend เอง
- ✅ Responsive ทุกอุปกรณ์
- ✅ ภาษาไทยเต็มรูปแบบ
- ✅ แก้ไขปรับแต่งได้ตามต้องการ
สรุป
Edge TTS Selfhost UI เป็น Text-to-Speech solution ที่ครบครัน:
- UI พร้อมใช้งาน - ไม่ต้องเขียน frontend
- ติดตั้งง่าย - Docker เดียวเสร็จ
- รองรับไทย - เสียงคุณภาพสูง
- ฟรี 100% - ไม่มีค่าใช้จ่าย
- Self-hosted - ข้อมูลปลอดภัย
เริ่มต้นใช้งาน:
git clone https://github.com/KONGKIAT888/edge-tts.git
cd edge-tts && docker-compose up -d
🌐 เข้าใช้งาน: http://localhost:8000
เพิ่มเติม
- Edge TTS Library: https://github.com/rany2/edge-tts
- FastAPI Documentation: https://fastapi.tiangolo.com/
หวังว่าบทความนี้จะเป็นประโยชน์ในการติดตั้งและใช้งาน Edge TTS Selfhost UI นะครับ!
- Username
- @Kongkiat
- Bio
- I collect sparks from tech, culture, and everyday chaos — then spin them into stories with a twist.
