Logo
Published on
เผยแพร่เมื่อ(แก้ไขเมื่อ 2 วันที่ผ่านมา)

สร้าง Text-to-Speech ด้วย Edge TTS

Edge TTS Selfhost UI

ในบทความนี้เราจะมาเรียนรู้วิธีการใช้งาน 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 ขณะเล่นเสียง

เตรียมสิ่งที่ต้องการ

ก่อนเริ่มต้น ตรวจสอบให้แน่ใจว่าคุณมี:

  1. Docker ติดตั้งแล้วบนเครื่อง
  2. Python 3.7+ (ถ้าต้องการทดสอบแบบ local)
  3. พื้นที่ว่าง ประมาณ 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

  1. ช่องกรอกข้อความ - พร้อมตัวนับจำนวนตัวอักษร
  2. เลือกเสียง - รองรับหลายภาษา รวมถึงภาษาไทย
  3. ปุ่ม Speak - ฟังเสียงแบบ real-time
  4. ปุ่ม Download - ดาวน์โหลดไฟล์เสียง
  5. Audio Player - พร้อม waveform visualization
  6. 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 - รองรับภาษาไทยเต็มรูปแบบ

ฟังก์ชันการทำงานที่ครบครัน (พร้อมใช้งาน)

  1. Text Input พร้อมตัวนับตัวอักษร
  2. Voice Selection เลือกเสียงได้จากหลายภาษา
  3. Real-time TTS แปลงข้อความเป็นเสียงทันที
  4. Audio Player พร้อม waveform visualization
  5. Download Feature ดาวน์โหลดไฟล์เสียง MP3
  6. 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 Selfhost UI นะครับ!

avatar
Username
@Kongkiat
Bio
I collect sparks from tech, culture, and everyday chaos — then spin them into stories with a twist.

Comment