From 9d1429eeb24f6396e8128fa2bb722420575a0891 Mon Sep 17 00:00:00 2001 From: YuanHui <31339626+alsesa@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:22:06 +0800 Subject: [PATCH] add web ui --- .gitignore | 1 + .python-version | 1 + main.py | 6 + pyproject.toml | 7 + web/.dockerignore | 53 ++++ web/.gitignore | 26 ++ web/DEPLOYMENT.md | 435 ++++++++++++++++++++++++++++ web/DOCKER_QUICKSTART.md | 195 +++++++++++++ web/Dockerfile | 42 +++ web/QUICKSTART.md | 212 ++++++++++++++ web/README.md | 297 ++++++++++++++++++++ web/app.js | 594 +++++++++++++++++++++++++++++++++++++++ web/build.sh | 40 +++ web/deploy.sh | 113 ++++++++ web/docker-compose.yml | 25 ++ web/icon-192.png | Bin 0 -> 12264 bytes web/icon-512.png | Bin 0 -> 22987 bytes web/icon.svg | 4 + web/index.html | 175 ++++++++++++ web/manifest.json | 40 +++ web/requirements.txt | 4 + web/server.py | 256 +++++++++++++++++ web/start.sh | 28 ++ web/styles.css | 488 ++++++++++++++++++++++++++++++++ web/sw.js | 142 ++++++++++ 25 files changed, 3184 insertions(+) create mode 100644 .python-version create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 web/.dockerignore create mode 100644 web/.gitignore create mode 100644 web/DEPLOYMENT.md create mode 100644 web/DOCKER_QUICKSTART.md create mode 100644 web/Dockerfile create mode 100644 web/QUICKSTART.md create mode 100644 web/README.md create mode 100644 web/app.js create mode 100755 web/build.sh create mode 100755 web/deploy.sh create mode 100644 web/docker-compose.yml create mode 100644 web/icon-192.png create mode 100644 web/icon-512.png create mode 100644 web/icon.svg create mode 100644 web/index.html create mode 100644 web/manifest.json create mode 100644 web/requirements.txt create mode 100755 web/server.py create mode 100755 web/start.sh create mode 100644 web/styles.css create mode 100644 web/sw.js diff --git a/.gitignore b/.gitignore index 8e093ea..acc657c 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,4 @@ cython_debug/ *.mp3 *.srt /.idea/ +.DS_Store diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/main.py b/main.py new file mode 100644 index 0000000..ab75f6b --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from edge-tts!") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1f39c8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "edge-tts" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..0976e14 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,53 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# Virtual environments +venv/ +env/ +ENV/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Documentation +README.md +QUICKSTART.md +*.md + +# Build scripts +build.sh +deploy.sh +create_icons.py +create_icons.sh + +# Test files +test_*.py +*_test.py + +# Logs +*.log + +# Temporary files +*.tmp +*.mp3 +*.wav +*.srt diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..2ac4ced --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.mp3 +*.wav +*.srt +create_icons.py +create_icons.sh diff --git a/web/DEPLOYMENT.md b/web/DEPLOYMENT.md new file mode 100644 index 0000000..56daf24 --- /dev/null +++ b/web/DEPLOYMENT.md @@ -0,0 +1,435 @@ +# Edge TTS Web UI - Docker Deployment Guide + +Complete guide for deploying Edge TTS Web UI to a remote server using Docker. + +## 📋 Prerequisites + +### Local Machine +- Docker installed +- Docker Compose installed +- SSH access to remote server +- rsync (for deployment script) + +### Remote Server +- Linux server (Ubuntu 20.04+ recommended) +- Docker installed +- Docker Compose installed +- Port 8000 open (or configure different port) +- Minimum 512MB RAM +- SSH access configured + +## 🚀 Quick Start + +### Option 1: Local Testing + +```bash +cd web + +# Build the image +./build.sh + +# Start with docker-compose +docker-compose up -d + +# Check logs +docker-compose logs -f + +# Access at http://localhost:8000 +``` + +### Option 2: Remote Deployment + +```bash +cd web + +# Deploy to remote server +REMOTE_HOST=192.168.1.100 ./deploy.sh + +# Or with custom user and path +REMOTE_HOST=myserver.com REMOTE_USER=deployer REMOTE_PATH=/opt/edge-tts ./deploy.sh +``` + +## 📦 Building the Docker Image + +### Build Locally + +```bash +# Build with default tag (latest) +./build.sh + +# Build with custom tag +./build.sh v1.0.0 +``` + +### Manual Build + +```bash +docker build -t edge-tts-web:latest . +``` + +## 🏃 Running the Container + +### Using Docker Compose (Recommended) + +```bash +# Start in background +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop +docker-compose down + +# Restart +docker-compose restart + +# View status +docker-compose ps +``` + +### Using Docker CLI + +```bash +# Run container +docker run -d \ + --name edge-tts-web \ + -p 8000:8000 \ + --restart unless-stopped \ + edge-tts-web:latest + +# View logs +docker logs -f edge-tts-web + +# Stop container +docker stop edge-tts-web + +# Remove container +docker rm edge-tts-web +``` + +## 🌐 Remote Server Deployment + +### Step-by-Step Manual Deployment + +#### 1. Install Docker on Remote Server + +```bash +# SSH into server +ssh user@your-server.com + +# Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh + +# Install Docker Compose +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose + +# Add user to docker group (optional) +sudo usermod -aG docker $USER +``` + +#### 2. Copy Files to Server + +```bash +# Create deployment directory +ssh user@your-server.com 'mkdir -p /opt/edge-tts' + +# Copy files (from local machine) +cd web +rsync -avz --exclude='venv' --exclude='.git' \ + ./ user@your-server.com:/opt/edge-tts/ +``` + +#### 3. Build and Start on Server + +```bash +# SSH into server +ssh user@your-server.com + +# Navigate to deployment directory +cd /opt/edge-tts + +# Build and start +docker-compose up -d + +# Check status +docker-compose ps +docker-compose logs -f +``` + +### Automated Deployment Script + +The `deploy.sh` script automates the entire deployment process: + +```bash +# Basic usage +REMOTE_HOST=192.168.1.100 ./deploy.sh + +# With custom configuration +REMOTE_HOST=myserver.com \ +REMOTE_USER=deployer \ +REMOTE_PATH=/home/deployer/edge-tts \ +./deploy.sh +``` + +**What the script does:** +1. ✅ Checks SSH connectivity +2. ✅ Creates remote directory +3. ✅ Copies all files to server +4. ✅ Stops existing containers +5. ✅ Builds new Docker image +6. ✅ Starts containers +7. ✅ Shows deployment status + +## 🔧 Configuration + +### Environment Variables + +Create a `.env` file for custom configuration: + +```bash +# .env +PYTHONUNBUFFERED=1 +# Add other environment variables as needed +``` + +### Custom Port + +To use a different port, edit `docker-compose.yml`: + +```yaml +ports: + - "3000:8000" # Host:Container +``` + +### Resource Limits + +Add resource limits in `docker-compose.yml`: + +```yaml +services: + edge-tts-web: + # ... other config + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M +``` + +## 🔒 Security Best Practices + +### 1. Use Non-Root User +The Dockerfile already creates a non-root user (`appuser`) + +### 2. Reverse Proxy with SSL + +Use Nginx or Traefik as reverse proxy: + +**Nginx example:** +```nginx +server { + listen 80; + server_name tts.yourdomain.com; + + location / { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +**With Let's Encrypt SSL:** +```bash +sudo apt install certbot python3-certbot-nginx +sudo certbot --nginx -d tts.yourdomain.com +``` + +### 3. Firewall Configuration + +```bash +# Allow only necessary ports +sudo ufw allow 22/tcp # SSH +sudo ufw allow 80/tcp # HTTP +sudo ufw allow 443/tcp # HTTPS +sudo ufw enable +``` + +### 4. Docker Socket Security + +Don't expose Docker socket unnecessarily. The current setup doesn't require it. + +## 📊 Monitoring + +### Check Container Status + +```bash +# Using docker-compose +docker-compose ps + +# Using docker CLI +docker ps +``` + +### View Logs + +```bash +# All logs +docker-compose logs + +# Follow logs (real-time) +docker-compose logs -f + +# Last 100 lines +docker-compose logs --tail=100 + +# Specific service +docker-compose logs edge-tts-web +``` + +### Health Checks + +The container includes a health check that runs every 30 seconds: + +```bash +# Check health status +docker inspect edge-tts-web --format='{{.State.Health.Status}}' + +# View health check logs +docker inspect edge-tts-web --format='{{json .State.Health}}' | jq +``` + +## 🔄 Updates and Maintenance + +### Update Application + +```bash +# Pull latest changes +git pull + +# Rebuild and restart +docker-compose up -d --build + +# Or use deployment script +REMOTE_HOST=your-server.com ./deploy.sh +``` + +### Backup and Restore + +```bash +# Backup (no persistent data currently) +# If you add persistent data, use Docker volumes + +# Create volume backup +docker run --rm -v edge-tts-data:/data -v $(pwd):/backup \ + alpine tar czf /backup/edge-tts-backup.tar.gz /data + +# Restore volume +docker run --rm -v edge-tts-data:/data -v $(pwd):/backup \ + alpine tar xzf /backup/edge-tts-backup.tar.gz -C / +``` + +### Clean Up + +```bash +# Stop and remove containers +docker-compose down + +# Remove images +docker rmi edge-tts-web:latest + +# Clean up unused resources +docker system prune -a +``` + +## 🐛 Troubleshooting + +### Container Won't Start + +```bash +# Check logs +docker-compose logs + +# Check container status +docker-compose ps + +# Rebuild from scratch +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +### Port Already in Use + +```bash +# Check what's using the port +sudo lsof -i :8000 + +# Kill the process or change port in docker-compose.yml +``` + +### Permission Denied Errors + +```bash +# On remote server, add user to docker group +sudo usermod -aG docker $USER +newgrp docker +``` + +### Health Check Failing + +```bash +# Check if app is responding +curl http://localhost:8000/api/health + +# Check health status +docker inspect edge-tts-web --format='{{json .State.Health}}' | jq +``` + +## 📁 File Structure + +``` +web/ +├── Dockerfile # Docker image definition +├── docker-compose.yml # Docker Compose configuration +├── .dockerignore # Files to exclude from image +├── build.sh # Build script +├── deploy.sh # Deployment script +├── server.py # FastAPI server +├── index.html # Web UI +├── app.js # Frontend logic +├── styles.css # Styling +├── manifest.json # PWA manifest +├── sw.js # Service worker +└── requirements.txt # Python dependencies +``` + +## 🌟 Production Recommendations + +1. **Use a Reverse Proxy**: Nginx or Traefik with SSL/TLS +2. **Set Up Monitoring**: Prometheus + Grafana +3. **Configure Logging**: Centralized logging with ELK or Loki +4. **Auto-Restart**: Use `restart: unless-stopped` in docker-compose +5. **Resource Limits**: Set appropriate CPU and memory limits +6. **Regular Backups**: If you add persistent data +7. **Security Updates**: Keep Docker and base images updated +8. **Domain Name**: Use a proper domain with DNS +9. **CDN**: Consider using a CDN for static assets +10. **Rate Limiting**: Implement rate limiting for API endpoints + +## 📞 Support + +For issues or questions: +- Check logs: `docker-compose logs -f` +- GitHub Issues: https://github.com/rany2/edge-tts/issues +- Review health checks: `docker inspect edge-tts-web` + +## 📝 License + +This deployment configuration is part of the Edge TTS project. diff --git a/web/DOCKER_QUICKSTART.md b/web/DOCKER_QUICKSTART.md new file mode 100644 index 0000000..121c577 --- /dev/null +++ b/web/DOCKER_QUICKSTART.md @@ -0,0 +1,195 @@ +# Docker Quick Start Guide + +## 🚀 Deploy in 3 Steps + +### Step 1: Build +```bash +cd web +./build.sh +``` + +### Step 2: Deploy to Remote Server +```bash +# Replace with your server IP/hostname +REMOTE_HOST=192.168.1.100 ./deploy.sh +``` + +### Step 3: Access +``` +http://YOUR_SERVER_IP:8000 +``` + +## 📋 Common Commands + +### Local Development +```bash +# Build and start +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop +docker-compose down + +# Restart +docker-compose restart +``` + +### Remote Deployment +```bash +# Basic deployment +REMOTE_HOST=192.168.1.100 ./deploy.sh + +# Custom user and path +REMOTE_HOST=myserver.com \ +REMOTE_USER=deployer \ +REMOTE_PATH=/opt/edge-tts \ +./deploy.sh +``` + +### Monitoring +```bash +# Container status +docker-compose ps + +# Health check +docker inspect edge-tts-web --format='{{.State.Health.Status}}' + +# Resource usage +docker stats edge-tts-web +``` + +### Troubleshooting +```bash +# View logs +docker-compose logs --tail=100 + +# Restart container +docker-compose restart + +# Rebuild from scratch +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +## 🔧 Configuration + +### Change Port +Edit `docker-compose.yml`: +```yaml +ports: + - "3000:8000" # Change 3000 to your desired port +``` + +### Environment Variables +Create `.env` file: +```bash +PYTHONUNBUFFERED=1 +# Add your variables here +``` + +## 🌐 Production Setup + +### 1. Use Reverse Proxy (Recommended) + +**Nginx:** +```nginx +server { + listen 80; + server_name tts.yourdomain.com; + + location / { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +**Install SSL:** +```bash +sudo certbot --nginx -d tts.yourdomain.com +``` + +### 2. Firewall Setup +```bash +sudo ufw allow 22/tcp # SSH +sudo ufw allow 80/tcp # HTTP +sudo ufw allow 443/tcp # HTTPS +sudo ufw enable +``` + +### 3. Auto-Updates (Optional) +```bash +# Add to crontab +0 2 * * * cd /opt/edge-tts && docker-compose pull && docker-compose up -d +``` + +## 📊 Monitoring + +### Check Health +```bash +curl http://localhost:8000/api/health +``` + +### View Metrics +```bash +docker stats edge-tts-web +``` + +## 🆘 Quick Fixes + +### Port Already in Use +```bash +# Find process using port +sudo lsof -i :8000 + +# Or change port in docker-compose.yml +``` + +### Permission Denied +```bash +sudo usermod -aG docker $USER +newgrp docker +``` + +### Container Won't Start +```bash +# Check logs +docker-compose logs + +# Rebuild +docker-compose build --no-cache +docker-compose up -d +``` + +## 📁 File Structure +``` +web/ +├── Dockerfile # Container definition +├── docker-compose.yml # Orchestration +├── build.sh # Build script +├── deploy.sh # Deploy script +├── server.py # Backend +└── [web files] # Frontend +``` + +## 🔗 Useful Links + +- Full Documentation: [DEPLOYMENT.md](DEPLOYMENT.md) +- Edge TTS Project: https://github.com/rany2/edge-tts +- Docker Docs: https://docs.docker.com + +## 💡 Tips + +1. **Always use reverse proxy in production** +2. **Enable SSL/TLS with Let's Encrypt** +3. **Set up monitoring and logging** +4. **Regular backups if you add persistent data** +5. **Keep Docker and images updated** + +--- + +**Need Help?** Check [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions! diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..9480929 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,42 @@ +# Edge TTS Web UI - Production Dockerfile +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application files +COPY *.py . +COPY *.html . +COPY *.css . +COPY *.js . +COPY *.json . +COPY *.png . 2>/dev/null || true +COPY *.svg . 2>/dev/null || true + +# Create non-root user for security +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" + +# Run the application +CMD ["python", "server.py", "--host", "0.0.0.0", "--port", "8000"] diff --git a/web/QUICKSTART.md b/web/QUICKSTART.md new file mode 100644 index 0000000..723f1da --- /dev/null +++ b/web/QUICKSTART.md @@ -0,0 +1,212 @@ +# Quick Start Guide + +## 🚀 Get Started in 3 Steps + +### 1. Install Dependencies + +```bash +cd web +pip install -r requirements.txt +``` + +### 2. Start the Server + +```bash +./start.sh +``` + +Or manually: +```bash +python3 server.py +``` + +### 3. Open Your Browser + +Visit: **http://localhost:8000** + +--- + +## ✨ Features at a Glance + +### Text to Speech +- Enter any text (up to 5000 characters) +- Select from 100+ voices in multiple languages +- Adjust speed, volume, and pitch +- Generate natural-sounding speech + +### Voice Selection +- Filter by language and gender +- Preview voice names and locales +- Save your favorite settings + +### Audio Controls +- Play audio directly in browser +- Download as MP3 files +- View generation history +- Quick reload from history + +### PWA Features +- Install as standalone app +- Offline support with service worker +- Works on desktop and mobile +- Responsive design + +--- + +## 📱 Install as App + +### On Desktop (Chrome/Edge) +1. Click the install icon in the address bar +2. Or look for "Install App" button in the UI +3. App will be added to your applications + +### On Mobile (Android) +1. Open in Chrome +2. Tap the menu (⋮) +3. Select "Add to Home screen" +4. App icon will appear on home screen + +### On iOS (Safari) +1. Tap the share button +2. Select "Add to Home Screen" +3. Name the app and add to home screen + +--- + +## 🎯 Quick Usage Tips + +### Generate Speech +1. Enter or paste text +2. Select a voice (default: English) +3. Adjust speed/volume/pitch if needed +4. Click "Generate Speech" +5. Audio player appears with playback controls + +### Download Audio +- Click "Download MP3" button +- File saves with timestamp and text snippet + +### Use History +- Recent generations saved automatically +- Click "Load" to restore settings +- Click "Delete" to remove from history + +### Filter Voices +- Use language dropdown for specific locales +- Use gender filter for Male/Female voices +- Voice list updates automatically + +--- + +## 🔧 Configuration + +### Change Port +```bash +python3 server.py --port 8080 +``` + +### Enable Hot Reload (Development) +```bash +python3 server.py --reload +``` + +### Bind to Specific Host +```bash +python3 server.py --host 127.0.0.1 +``` + +--- + +## ⚡ API Usage + +### Test with cURL + +Get voices: +```bash +curl http://localhost:8000/api/voices +``` + +Generate speech: +```bash +curl -X POST http://localhost:8000/api/synthesize \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Hello, world!", + "voice": "en-US-EmmaMultilingualNeural", + "rate": "+0%", + "volume": "+0%", + "pitch": "+0Hz" + }' \ + --output speech.mp3 +``` + +--- + +## 🎨 Customization + +### Update Theme Color + +Edit `styles.css`: +```css +:root { + --primary-color: #2563eb; /* Your color here */ +} +``` + +Update `manifest.json`: +```json +{ + "theme_color": "#2563eb" +} +``` + +### Replace Icons + +Create PNG icons: +- `icon-192.png` - 192x192 pixels +- `icon-512.png` - 512x512 pixels + +Use any image editing tool or online icon generator. + +--- + +## 🐛 Troubleshooting + +### Port Already in Use +```bash +python3 server.py --port 8080 +``` + +### Dependencies Not Found +```bash +pip3 install -r requirements.txt +``` + +### Voices Not Loading +- Check internet connection +- Check server logs for errors +- Try refreshing the page + +### Service Worker Issues +- Clear browser cache +- Hard refresh (Ctrl+Shift+R or Cmd+Shift+R) +- Check browser console for errors + +--- + +## 📚 More Information + +See [README.md](README.md) for detailed documentation including: +- Full API reference +- Deployment guide +- Docker setup +- Production considerations +- Contributing guidelines + +--- + +## 🎉 You're All Set! + +Enjoy using Edge TTS Web UI! + +For issues or questions, visit: https://github.com/rany2/edge-tts diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..b83837b --- /dev/null +++ b/web/README.md @@ -0,0 +1,297 @@ +# Edge TTS Web UI + +A Progressive Web App (PWA) for converting text to speech using Microsoft Edge's online TTS service. + +## Features + +- 🎙️ **Text to Speech**: Convert any text to natural-sounding speech +- 🌍 **Multiple Languages**: Support for 100+ voices in various languages +- 🎛️ **Voice Customization**: Adjust speed, volume, and pitch +- 📱 **PWA Support**: Install as an app on any device +- 💾 **Offline Support**: Service worker caching for offline usage +- 📝 **History**: Keep track of recent generations +- ⬇️ **Download**: Save generated audio as MP3 files + +## Installation + +### Prerequisites + +- Python 3.8 or higher +- pip (Python package manager) + +### Setup + +1. Navigate to the web directory: +```bash +cd web +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + +## Usage + +### Start the Server + +```bash +python server.py +``` + +Or with custom options: +```bash +python server.py --host 0.0.0.0 --port 8000 +``` + +Options: +- `--host`: Host to bind to (default: 0.0.0.0) +- `--port`: Port to bind to (default: 8000) +- `--reload`: Enable auto-reload for development + +### Access the Web UI + +Open your browser and navigate to: +``` +http://localhost:8000 +``` + +### Install as PWA + +1. Open the web UI in a modern browser (Chrome, Edge, Safari, Firefox) +2. Look for the install prompt or click "Install App" button +3. The app will be added to your home screen/app drawer + +## API Endpoints + +The server provides the following REST API endpoints: + +### GET /api/health +Health check endpoint + +**Response:** +```json +{ + "status": "healthy", + "service": "edge-tts-api" +} +``` + +### GET /api/voices +Get list of all available voices + +**Response:** +```json +[ + { + "Name": "en-US-EmmaMultilingualNeural", + "ShortName": "en-US-EmmaMultilingualNeural", + "Gender": "Female", + "Locale": "en-US", + "LocaleName": "English (United States)", + ... + } +] +``` + +### POST /api/synthesize +Synthesize speech from text + +**Request Body:** +```json +{ + "text": "Hello, world!", + "voice": "en-US-EmmaMultilingualNeural", + "rate": "+0%", + "volume": "+0%", + "pitch": "+0Hz" +} +``` + +**Response:** +Returns MP3 audio file + +**Parameters:** +- `text` (required): Text to convert (max 5000 characters) +- `voice` (optional): Voice name (default: "en-US-EmmaMultilingualNeural") +- `rate` (optional): Speech rate from -100% to +100% (default: "+0%") +- `volume` (optional): Volume from -100% to +100% (default: "+0%") +- `pitch` (optional): Pitch from -500Hz to +500Hz (default: "+0Hz") + +### POST /api/synthesize-with-subtitles +Synthesize speech with subtitle generation + +**Request Body:** +Same as /api/synthesize + +**Response:** +```json +{ + "audio": "base64_encoded_audio_data", + "subtitles": "SRT formatted subtitles", + "format": "mp3" +} +``` + +## File Structure + +``` +web/ +├── index.html # Main HTML page +├── styles.css # Styles and theme +├── app.js # Client-side JavaScript +├── manifest.json # PWA manifest +├── sw.js # Service worker +├── server.py # FastAPI backend server +├── requirements.txt # Python dependencies +├── icon-192.png # App icon (192x192) +├── icon-512.png # App icon (512x512) +└── README.md # This file +``` + +## Development + +### Running in Development Mode + +```bash +python server.py --reload +``` + +This enables auto-reload when you modify the code. + +### Testing + +Test the API endpoints using curl: + +```bash +# Get voices +curl http://localhost:8000/api/voices + +# Synthesize speech +curl -X POST http://localhost:8000/api/synthesize \ + -H "Content-Type: application/json" \ + -d '{"text":"Hello world","voice":"en-US-EmmaMultilingualNeural"}' \ + --output speech.mp3 +``` + +### Customization + +#### Update Icons + +Replace `icon-192.png` and `icon-512.png` with your own icons. + +For best results, create: +- 192x192 PNG for mobile devices +- 512x512 PNG for high-resolution displays + +#### Update Theme Color + +Edit the `--primary-color` variable in [styles.css](styles.css): + +```css +:root { + --primary-color: #2563eb; /* Change this color */ +} +``` + +Also update `theme_color` in [manifest.json](manifest.json). + +## Browser Support + +### PWA Features +- ✅ Chrome/Edge (Desktop & Mobile) +- ✅ Safari (iOS 11.3+) +- ✅ Firefox (Desktop & Android) +- ✅ Samsung Internet + +### Service Worker +- ✅ All modern browsers +- ❌ IE11 (not supported) + +## Troubleshooting + +### Port Already in Use + +If port 8000 is already in use: +```bash +python server.py --port 8080 +``` + +### Icons Not Showing + +Make sure `icon-192.png` and `icon-512.png` exist in the web directory. + +### Voices Not Loading + +Check the server logs for errors. The server needs internet connection to fetch voices from Microsoft's API. + +### CORS Issues + +The server is configured to allow all origins for development. For production, update the CORS settings in [server.py](server.py): + +```python +app.add_middleware( + CORSMiddleware, + allow_origins=["https://yourdomain.com"], # Update this + ... +) +``` + +## Deployment + +### Production Considerations + +1. **Use a production ASGI server**: Uvicorn with multiple workers + ```bash + uvicorn server:app --host 0.0.0.0 --port 8000 --workers 4 + ``` + +2. **Use a reverse proxy**: nginx or Apache for SSL/TLS + +3. **Set environment variables**: + ```bash + export EDGE_TTS_HOST=0.0.0.0 + export EDGE_TTS_PORT=8000 + ``` + +4. **Update CORS settings**: Restrict to your domain + +5. **Enable HTTPS**: Required for PWA installation + +### Docker Deployment + +Create a `Dockerfile`: + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["python", "server.py", "--host", "0.0.0.0", "--port", "8000"] +``` + +Build and run: +```bash +docker build -t edge-tts-web . +docker run -p 8000:8000 edge-tts-web +``` + +## License + +This web UI is built on top of [edge-tts](https://github.com/rany2/edge-tts). + +## Contributing + +Contributions are welcome! Please feel free to submit issues or pull requests. + +## Credits + +- **edge-tts**: The underlying TTS library by [@rany2](https://github.com/rany2) +- **Microsoft Edge TTS**: The text-to-speech service diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..0c5f1bf --- /dev/null +++ b/web/app.js @@ -0,0 +1,594 @@ +// Configuration +const API_BASE_URL = window.location.hostname === 'localhost' + ? 'http://localhost:8000/api' + : '/api'; + +// Sample sentences for different languages +const SAMPLE_SENTENCES = { + 'ar': ['مرحبا، كيف حالك اليوم؟', 'الطقس جميل اليوم.', 'أتمنى لك يوما سعيدا.'], + 'bg': ['Здравейте, как сте днес?', 'Времето е хубаво днес.', 'Желая ви приятен ден.'], + 'ca': ['Hola, com estàs avui?', 'El temps és agradable avui.', 'Que tinguis un bon dia.'], + 'cs': ['Ahoj, jak se máš dnes?', 'Počasí je dnes pěkné.', 'Přeji ti hezký den.'], + 'da': ['Hej, hvordan har du det i dag?', 'Vejret er dejligt i dag.', 'Hav en god dag.'], + 'de': ['Hallo, wie geht es dir heute?', 'Das Wetter ist heute schön.', 'Ich wünsche dir einen schönen Tag.'], + 'el': ['Γεια σου, πώς είσαι σήμερα;', 'Ο καιρός είναι ωραίος σήμερα.', 'Σου εύχομαι μια όμορφη μέρα.'], + 'en': ['Hello, how are you today?', 'The weather is nice today.', 'Have a wonderful day!'], + 'es': ['Hola, ¿cómo estás hoy?', 'El clima está agradable hoy.', '¡Que tengas un buen día!'], + 'fi': ['Hei, mitä kuuluu tänään?', 'Sää on kaunis tänään.', 'Mukavaa päivää!'], + 'fr': ['Bonjour, comment allez-vous aujourd\'hui?', 'Le temps est agréable aujourd\'hui.', 'Passez une bonne journée!'], + 'hi': ['नमस्ते, आज आप कैसे हैं?', 'आज मौसम अच्छा है।', 'आपका दिन शुभ हो।'], + 'hr': ['Bok, kako si danas?', 'Vrijeme je lijepo danas.', 'Želim ti lijep dan.'], + 'hu': ['Szia, hogy vagy ma?', 'Az idő szép ma.', 'Szép napot kívánok!'], + 'id': ['Halo, apa kabar hari ini?', 'Cuacanya bagus hari ini.', 'Semoga harimu menyenangkan!'], + 'it': ['Ciao, come stai oggi?', 'Il tempo è bello oggi.', 'Ti auguro una buona giornata!'], + 'ja': ['こんにちは、今日はお元気ですか?', '今日は天気がいいですね。', '良い一日をお過ごしください。'], + 'ko': ['안녕하세요, 오늘은 어떠세요?', '오늘 날씨가 좋네요.', '좋은 하루 보내세요!'], + 'nl': ['Hallo, hoe gaat het vandaag?', 'Het weer is mooi vandaag.', 'Fijne dag gewenst!'], + 'no': ['Hei, hvordan har du det i dag?', 'Været er fint i dag.', 'Ha en fin dag!'], + 'pl': ['Cześć, jak się masz dzisiaj?', 'Pogoda jest ładna dzisiaj.', 'Miłego dnia!'], + 'pt': ['Olá, como você está hoje?', 'O tempo está agradável hoje.', 'Tenha um ótimo dia!'], + 'ro': ['Bună, ce mai faci astăzi?', 'Vremea este frumoasă astăzi.', 'O zi bună!'], + 'ru': ['Привет, как дела сегодня?', 'Погода сегодня хорошая.', 'Хорошего дня!'], + 'sk': ['Ahoj, ako sa máš dnes?', 'Počasie je dnes pekné.', 'Prajem ti pekný deň.'], + 'sv': ['Hej, hur mår du idag?', 'Vädret är fint idag.', 'Ha en trevlig dag!'], + 'th': ['สวัสดี วันนี้เป็นอย่างไรบ้าง?', 'อากาศดีวันนี้.', 'ขอให้มีความสุขตลอดวัน!'], + 'tr': ['Merhaba, bugün nasılsın?', 'Hava bugün güzel.', 'İyi günler dilerim!'], + 'uk': ['Привіт, як справи сьогодні?', 'Погода сьогодні гарна.', 'Гарного дня!'], + 'vi': ['Xin chào, hôm nay bạn thế nào?', 'Thời tiết hôm nay đẹp.', 'Chúc bạn một ngày tốt lành!'], + 'zh': ['你好,今天过得怎么样?', '今天天气真好。', '祝你有美好的一天!'], + // Cantonese (yue-CN) + 'yue': ['你好,今日點呀?', '今日天氣好好。', '祝你有美好嘅一天!'], + // Wu Chinese (wuu-CN) - uses Simplified Chinese + 'wuu': ['侬好,今朝好伐?', '今朝天气老好额。', '祝侬开心!'], +}; + +// State +let voices = []; +let filteredVoices = []; +let currentAudioUrl = null; +let currentTestAudioUrl = null; +let history = []; +let deferredPrompt = null; + +// DOM Elements +const textInput = document.getElementById('textInput'); +const charCount = document.getElementById('charCount'); +const voiceSelect = document.getElementById('voiceSelect'); +const languageSelect = document.getElementById('languageSelect'); +const genderFilter = document.getElementById('genderFilter'); +const rateSlider = document.getElementById('rateSlider'); +const rateValue = document.getElementById('rateValue'); +const volumeSlider = document.getElementById('volumeSlider'); +const volumeValue = document.getElementById('volumeValue'); +const pitchSlider = document.getElementById('pitchSlider'); +const pitchValue = document.getElementById('pitchValue'); +const generateBtn = document.getElementById('generateBtn'); +const clearBtn = document.getElementById('clearBtn'); +const testVoiceBtn = document.getElementById('testVoiceBtn'); +const progressBar = document.getElementById('progressBar'); +const statusMessage = document.getElementById('statusMessage'); +const audioSection = document.getElementById('audioSection'); +const audioPlayer = document.getElementById('audioPlayer'); +const downloadBtn = document.getElementById('downloadBtn'); +const historyList = document.getElementById('historyList'); +const onlineStatus = document.getElementById('onlineStatus'); +const installPrompt = document.getElementById('installPrompt'); +const installBtn = document.getElementById('installBtn'); + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + loadVoices(); + loadHistory(); + setupEventListeners(); + setupPWA(); + updateOnlineStatus(); +}); + +// Event Listeners +function setupEventListeners() { + textInput.addEventListener('input', updateCharCount); + + languageSelect.addEventListener('change', () => { + filterVoices(); + updateTestVoiceButton(); + }); + genderFilter.addEventListener('change', filterVoices); + voiceSelect.addEventListener('change', updateTestVoiceButton); + + rateSlider.addEventListener('input', (e) => { + const value = e.target.value; + rateValue.textContent = `${value >= 0 ? '+' : ''}${value}%`; + }); + + volumeSlider.addEventListener('input', (e) => { + const value = e.target.value; + volumeValue.textContent = `${value >= 0 ? '+' : ''}${value}%`; + }); + + pitchSlider.addEventListener('input', (e) => { + const value = e.target.value; + pitchValue.textContent = `${value >= 0 ? '+' : ''}${value}Hz`; + }); + + generateBtn.addEventListener('click', generateSpeech); + clearBtn.addEventListener('click', clearForm); + testVoiceBtn.addEventListener('click', testVoice); + downloadBtn.addEventListener('click', downloadAudio); + + window.addEventListener('online', updateOnlineStatus); + window.addEventListener('offline', updateOnlineStatus); +} + +// Character count +function updateCharCount() { + const count = textInput.value.length; + charCount.textContent = count; + + if (count > 4500) { + charCount.style.color = 'var(--error-color)'; + } else if (count > 4000) { + charCount.style.color = 'var(--primary-color)'; + } else { + charCount.style.color = ''; + } +} + +// Load voices from API +async function loadVoices() { + try { + // Add cache busting to ensure fresh data + const response = await fetch(`${API_BASE_URL}/voices?_=${Date.now()}`); + if (!response.ok) throw new Error('Failed to load voices'); + + voices = await response.json(); + populateLanguageSelect(); + + showStatus('Voices loaded successfully', 'success'); + console.log(`Loaded ${voices.length} voices from API`); + } catch (error) { + console.error('Error loading voices:', error); + showStatus('Failed to load voices. Please check the server connection.', 'error'); + } +} + +// Populate language select +function populateLanguageSelect() { + const languages = [...new Set(voices.map(v => v.Locale))].sort(); + + languageSelect.innerHTML = ''; + languages.forEach(lang => { + const option = document.createElement('option'); + option.value = lang; + option.textContent = getLanguageName(lang); + languageSelect.appendChild(option); + }); +} + +// Get language name from locale +function getLanguageName(locale) { + const names = voices.filter(v => v.Locale === locale); + return names.length > 0 ? names[0].LocaleName : locale; +} + +// Filter voices based on selected language and gender +function filterVoices() { + const selectedLanguage = languageSelect.value; + const selectedGender = genderFilter.value; + + // If no language is selected, clear voice dropdown + if (!selectedLanguage) { + voiceSelect.innerHTML = ''; + filteredVoices = []; + return; + } + + // Filter voices by language and gender + filteredVoices = voices.filter(voice => { + const languageMatch = voice.Locale === selectedLanguage; + const genderMatch = !selectedGender || voice.Gender === selectedGender; + return languageMatch && genderMatch; + }); + + populateVoiceSelect(); +} + +// Populate voice select +function populateVoiceSelect() { + voiceSelect.innerHTML = ''; + + if (filteredVoices.length === 0) { + voiceSelect.innerHTML = ''; + return; + } + + // Sort voices alphabetically by LocalName + const sortedVoices = [...filteredVoices].sort((a, b) => { + return a.LocalName.localeCompare(b.LocalName); + }); + + sortedVoices.forEach(voice => { + const option = document.createElement('option'); + option.value = voice.Name; + option.textContent = `${voice.LocalName} (${voice.Gender})`; + voiceSelect.appendChild(option); + }); +} + +// Generate speech +async function generateSpeech() { + const text = textInput.value.trim(); + + if (!text) { + showStatus('Please enter some text', 'error'); + return; + } + + if (text.length > 5000) { + showStatus('Text exceeds maximum length of 5000 characters', 'error'); + return; + } + + const voice = voiceSelect.value; + if (!voice) { + showStatus('Please select a voice', 'error'); + return; + } + + const params = { + text: text, + voice: voice, + rate: `${rateSlider.value >= 0 ? '+' : ''}${rateSlider.value}%`, + volume: `${volumeSlider.value >= 0 ? '+' : ''}${volumeSlider.value}%`, + pitch: `${pitchSlider.value >= 0 ? '+' : ''}${pitchSlider.value}Hz` + }; + + try { + generateBtn.disabled = true; + generateBtn.innerHTML = ' Generating...'; + progressBar.style.display = 'block'; + hideStatus(); + + const response = await fetch(`${API_BASE_URL}/synthesize`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to generate speech' })); + const errorMsg = error.detail || 'Failed to generate speech'; + + // Check if it's an invalid voice error + if (errorMsg.includes('No audio') || errorMsg.includes('voice')) { + throw new Error(`Voice error: ${errorMsg}. Try selecting a different voice or refresh the page.`); + } + throw new Error(errorMsg); + } + + const blob = await response.blob(); + + // Clean up previous audio URL + if (currentAudioUrl) { + URL.revokeObjectURL(currentAudioUrl); + } + + currentAudioUrl = URL.createObjectURL(blob); + audioPlayer.src = currentAudioUrl; + audioSection.style.display = 'block'; + + // Add to history + const selectedVoice = voices.find(v => v.Name === voice); + addToHistory({ + text: text, + voice: voice, + voiceName: voiceSelect.options[voiceSelect.selectedIndex].text, + locale: selectedVoice ? selectedVoice.Locale : '', + localeName: selectedVoice ? selectedVoice.LocaleName : '', + params: params, + timestamp: new Date().toISOString() + }); + + showStatus('Speech generated successfully!', 'success'); + + } catch (error) { + console.error('Error generating speech:', error); + showStatus(error.message, 'error'); + } finally { + generateBtn.disabled = false; + generateBtn.innerHTML = '🎵 Generate Speech'; + progressBar.style.display = 'none'; + } +} + +// Download audio +function downloadAudio() { + if (!currentAudioUrl) return; + + const text = textInput.value.substring(0, 30).replace(/[^a-z0-9]/gi, '_'); + const filename = `edge-tts-${text}-${Date.now()}.mp3`; + + const a = document.createElement('a'); + a.href = currentAudioUrl; + a.download = filename; + a.click(); +} + +// Update test voice button state +function updateTestVoiceButton() { + const hasVoice = voiceSelect.value && voiceSelect.value !== ''; + testVoiceBtn.disabled = !hasVoice; +} + +// Get sample sentence for language +function getSampleSentence(locale) { + // Extract language code (e.g., 'en' from 'en-US') + const langCode = locale.split('-')[0]; + + // Check for exact locale match first (for special cases like yue-CN, wuu-CN) + if (SAMPLE_SENTENCES[locale]) { + const sentences = SAMPLE_SENTENCES[locale]; + return sentences[Math.floor(Math.random() * sentences.length)]; + } + + // Then check for language code match + if (SAMPLE_SENTENCES[langCode]) { + const sentences = SAMPLE_SENTENCES[langCode]; + return sentences[Math.floor(Math.random() * sentences.length)]; + } + + // Default to English + const sentences = SAMPLE_SENTENCES['en']; + return sentences[Math.floor(Math.random() * sentences.length)]; +} + +// Test voice with sample sentence +async function testVoice() { + const voice = voiceSelect.value; + const selectedLanguage = languageSelect.value; + + if (!voice || !selectedLanguage) { + showStatus('Please select a voice first', 'error'); + return; + } + + // Get sample sentence + const sampleText = getSampleSentence(selectedLanguage); + + const params = { + text: sampleText, + voice: voice, + rate: `${rateSlider.value >= 0 ? '+' : ''}${rateSlider.value}%`, + volume: `${volumeSlider.value >= 0 ? '+' : ''}${volumeSlider.value}%`, + pitch: `${pitchSlider.value >= 0 ? '+' : ''}${pitchSlider.value}Hz` + }; + + try { + testVoiceBtn.disabled = true; + testVoiceBtn.innerHTML = ' Testing...'; + + const response = await fetch(`${API_BASE_URL}/synthesize`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to generate test speech' })); + const errorMsg = error.detail || 'Failed to generate test speech'; + + // Check if it's an invalid voice error + if (errorMsg.includes('No audio') || errorMsg.includes('voice')) { + throw new Error(`Voice error: ${errorMsg}. This voice may not be available. Try refreshing the page.`); + } + throw new Error(errorMsg); + } + + const blob = await response.blob(); + + // Clean up previous test audio URL + if (currentTestAudioUrl) { + URL.revokeObjectURL(currentTestAudioUrl); + } + + currentTestAudioUrl = URL.createObjectURL(blob); + + // Play audio automatically + const testAudio = new Audio(currentTestAudioUrl); + testAudio.play(); + + showStatus(`Testing voice: "${sampleText}"`, 'info'); + + } catch (error) { + console.error('Error testing voice:', error); + showStatus(error.message, 'error'); + } finally { + testVoiceBtn.disabled = false; + testVoiceBtn.innerHTML = '🎧 Test Voice'; + } +} + +// Clear form +function clearForm() { + textInput.value = ''; + updateCharCount(); + rateSlider.value = 0; + volumeSlider.value = 0; + pitchSlider.value = 0; + rateValue.textContent = '+0%'; + volumeValue.textContent = '+0%'; + pitchValue.textContent = '+0Hz'; + hideStatus(); +} + +// History management +function loadHistory() { + const saved = localStorage.getItem('tts_history'); + if (saved) { + history = JSON.parse(saved); + renderHistory(); + } +} + +function saveHistory() { + // Keep only last 10 items + history = history.slice(0, 10); + localStorage.setItem('tts_history', JSON.stringify(history)); +} + +function addToHistory(item) { + history.unshift(item); + saveHistory(); + renderHistory(); +} + +function renderHistory() { + if (history.length === 0) { + historyList.innerHTML = '

No recent generations yet

'; + return; + } + + historyList.innerHTML = ''; + + history.forEach((item, index) => { + const div = document.createElement('div'); + div.className = 'history-item'; + + const date = new Date(item.timestamp); + const timeAgo = getTimeAgo(date); + + const languageInfo = item.localeName ? ` - ${item.localeName}` : ''; + + div.innerHTML = ` +
+
+ ${escapeHtml(item.text)} +
+
${timeAgo}
+
+
${escapeHtml(item.voiceName)}${languageInfo}
+
+ + +
+ `; + + historyList.appendChild(div); + }); +} + +function loadHistoryItem(index) { + const item = history[index]; + + textInput.value = item.text; + updateCharCount(); + + // Find the voice in the voices list to get its locale + const voice = voices.find(v => v.Name === item.voice); + if (voice) { + // Set language first + languageSelect.value = voice.Locale; + // Trigger filter to populate voice dropdown + filterVoices(); + // Then set the specific voice + voiceSelect.value = item.voice; + } + + // Set parameters + if (item.params) { + rateSlider.value = parseInt(item.params.rate); + volumeSlider.value = parseInt(item.params.volume); + pitchSlider.value = parseInt(item.params.pitch); + rateValue.textContent = item.params.rate; + volumeValue.textContent = item.params.volume; + pitchValue.textContent = item.params.pitch; + } + + window.scrollTo({ top: 0, behavior: 'smooth' }); + showStatus('History item loaded', 'info'); +} + +function deleteHistoryItem(index) { + history.splice(index, 1); + saveHistory(); + renderHistory(); +} + +// Utilities +function showStatus(message, type = 'info') { + statusMessage.textContent = message; + statusMessage.className = `status-message ${type}`; +} + +function hideStatus() { + statusMessage.className = 'status-message'; +} + +function getTimeAgo(date) { + const seconds = Math.floor((new Date() - date) / 1000); + + if (seconds < 60) return 'Just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function updateOnlineStatus() { + if (navigator.onLine) { + onlineStatus.textContent = '● Online'; + onlineStatus.className = 'online'; + } else { + onlineStatus.textContent = '● Offline'; + onlineStatus.className = 'offline'; + } +} + +// PWA Setup +function setupPWA() { + // Register service worker + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('sw.js') + .then(registration => { + console.log('Service Worker registered:', registration); + }) + .catch(error => { + console.error('Service Worker registration failed:', error); + }); + } + + // Install prompt + window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferredPrompt = e; + installPrompt.style.display = 'inline'; + }); + + installBtn.addEventListener('click', async () => { + if (!deferredPrompt) return; + + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + + console.log(`User response to install prompt: ${outcome}`); + deferredPrompt = null; + installPrompt.style.display = 'none'; + }); + + window.addEventListener('appinstalled', () => { + console.log('PWA installed'); + installPrompt.style.display = 'none'; + }); +} diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 0000000..30003ce --- /dev/null +++ b/web/build.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Build script for Edge TTS Web UI Docker image + +set -e # Exit on error + +echo "🏗️ Building Edge TTS Web UI Docker Image" +echo "==========================================" +echo "" + +# Configuration +IMAGE_NAME="edge-tts-web" +IMAGE_TAG="${1:-latest}" +FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}" + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo "❌ Docker is not installed. Please install Docker first." + exit 1 +fi + +# Build the Docker image +echo "📦 Building Docker image: ${FULL_IMAGE_NAME}" +docker build -t "${FULL_IMAGE_NAME}" . + +if [ $? -eq 0 ]; then + echo "" + echo "✅ Build successful!" + echo "" + echo "Image details:" + docker images | grep "${IMAGE_NAME}" | head -n 1 + echo "" + echo "To run the container:" + echo " docker run -d -p 8000:8000 --name edge-tts ${FULL_IMAGE_NAME}" + echo "" + echo "Or use docker-compose:" + echo " docker-compose up -d" +else + echo "❌ Build failed!" + exit 1 +fi diff --git a/web/deploy.sh b/web/deploy.sh new file mode 100755 index 0000000..10d2867 --- /dev/null +++ b/web/deploy.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Deployment script for Edge TTS Web UI to remote server + +set -e # Exit on error + +echo "🚀 Edge TTS Web UI - Remote Deployment Script" +echo "==============================================" +echo "" + +# Configuration - Edit these values for your server +REMOTE_USER="${REMOTE_USER:-root}" +REMOTE_HOST="${REMOTE_HOST:-your-server.com}" +REMOTE_PATH="${REMOTE_PATH:-/opt/edge-tts}" +IMAGE_NAME="edge-tts-web" +IMAGE_TAG="${1:-latest}" + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Function to print colored output +print_info() { + echo -e "${GREEN}ℹ️ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" + exit 1 +} + +# Check if required variables are set +if [ "$REMOTE_HOST" = "your-server.com" ]; then + print_warning "Please configure REMOTE_HOST before deployment" + echo "" + echo "Usage:" + echo " REMOTE_HOST=192.168.1.100 ./deploy.sh" + echo " REMOTE_HOST=myserver.com REMOTE_USER=deployer ./deploy.sh" + echo "" + exit 1 +fi + +# Check if SSH key is available +if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "${REMOTE_USER}@${REMOTE_HOST}" exit 2>/dev/null; then + print_warning "SSH key authentication not configured or server unreachable" + print_info "You may be prompted for password multiple times" +fi + +print_info "Deployment Configuration:" +echo " Remote Host: ${REMOTE_USER}@${REMOTE_HOST}" +echo " Remote Path: ${REMOTE_PATH}" +echo " Image: ${IMAGE_NAME}:${IMAGE_TAG}" +echo "" + +read -p "Continue with deployment? (y/n) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Deployment cancelled" + exit 0 +fi + +# Step 1: Create remote directory +print_info "Creating remote directory..." +ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_PATH}" + +# Step 2: Copy files to remote server +print_info "Copying files to remote server..." +rsync -avz --progress \ + --exclude='*.pyc' \ + --exclude='__pycache__' \ + --exclude='.git' \ + --exclude='venv' \ + --exclude='.venv' \ + --exclude='*.mp3' \ + --exclude='*.log' \ + ./ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/" + +# Step 3: Build and start containers on remote server +print_info "Building and starting containers on remote server..." +ssh "${REMOTE_USER}@${REMOTE_HOST}" << EOF + cd ${REMOTE_PATH} + + # Stop existing containers + docker-compose down 2>/dev/null || true + + # Build new image + docker-compose build + + # Start containers + docker-compose up -d + + # Show status + echo "" + echo "Container status:" + docker-compose ps +EOF + +if [ $? -eq 0 ]; then + print_info "Deployment successful! ✅" + echo "" + echo "Access your application at:" + echo " http://${REMOTE_HOST}:8000" + echo "" + echo "To check logs:" + echo " ssh ${REMOTE_USER}@${REMOTE_HOST} 'cd ${REMOTE_PATH} && docker-compose logs -f'" +else + print_error "Deployment failed!" +fi diff --git a/web/docker-compose.yml b/web/docker-compose.yml new file mode 100644 index 0000000..3801b8c --- /dev/null +++ b/web/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + edge-tts-web: + build: + context: . + dockerfile: Dockerfile + container_name: edge-tts-web + ports: + - "8000:8000" + environment: + - PYTHONUNBUFFERED=1 + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + networks: + - edge-tts-network + +networks: + edge-tts-network: + driver: bridge diff --git a/web/icon-192.png b/web/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..8ea12e99391986a90605020d7dba7990277ebe07 GIT binary patch literal 12264 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℘pEV_`_#9-HNHsURW`bIuEP#%sCA$hjz&-nWIe8L1N z)dx)D!&99zz2n03wEGmb8$t(N%Z75M%~WO2!cZ*tn}qD__g@8iT=Kbt$BC4^jW41a zDQkfw>56~sjbZ=-URk&~$>5jXW!2iI^J+t+_S(S6W==?s=)8AbO;rln6`Wzia1hTBH73wldezjjd5emwpR$d;D|y)#ipnqXAvn}UoVMSFo3dLkvJ_d z4uEl!bVTHJ-1&SsY=(4O(AW|iW)9|*9!+HH=ZqO(X>!mQ zizk;k0w{Xf3$m)syg_6c$Ekcv0)=dAeuP9ORvi`2_~(y8SUIT zDJL{bF-OI3_xPUUu`f=wZ~W(FwBKa@A~ZEr#z%C@)`=92@?aPs3LOgDuQk<6msoNB zAfI*d8av=FO~&U-H@eWO*Gg2b3_ap##((XFlX9G7?>+x-I>iWsOT!}E1{|AUj4uSQ z$ria0jigEF7U+BCIja}Hp;&g>j8<@+{!R9!t_PPOn&Be7_Q%XT8)fDWXaMh@25$wP zg_!R-R3JQI4`J+Gs~G#N{X`f?hLtgn6{Op}Y;5e2NqQ}=w#Uqri?Uxo#xopuH8B!Z zN4U%gWU>ijd^f!qpX;B#8rL=Jz$2v9d4Cm{SIKll-K|wCc=_N@F|~2Q9v3bh5XTll z(tGZ5DDmmK{XaS)eNf}X%WyVP5dSRjycfMqnQ;Dlz9~Oi*xxLyFnfxsaKww$k8bd| z@2N$W`Jg5~b;Z$#-_zhfwC2=r4{Oh8h6D-E0;?JiF$27UhPGpDT$DrlS>e-{Mr-l6 zEortgBQLdN=eH1v^IgdNfH0rMUOJa0Y1XM=QLdj=d#fyb7Is6qs2c{ zx1)YANHdP}Q4>HmNJ-LI`(hEv%G4|G1}Oqv|8qg=H+Pg{IS>z3v%af3_Nb9IHSDxW zLUcgOu!U&7SQUW|`-R%*69p}3TQ&O0_iS_exF{di)Q~D?iI06nt(|EHRa#A}XC0LQ z<%RMr+}Eiu_=}sdSmW`ev}oNf(_%~x$B{aJDb2|Y-mrZr#piv)@|)f98six2d)=6h zvY-*>BpOZeeqYIiCU!4jHt)}e>d6YI@?SO=WstWefq4#G;+ya2Od^JXMl*5H}i1nCN$E9CN71nvzgrFkbETnPb9mOtXy z=k&g(IV3zc6>|-LUcqb1wWazO2wQr)J*jVuSJbCTC3#Ofjdj=YP_An>gdk=)NPwA6 zzvA6WQ@8zGvC%=m!`n+B?ADNxr_EwzABSuI=^2#p8^FHW6wGm_rDwZnR%}% zBFlBeH;^C6;risf9{HaG@YCoSo~!$73Odht8i6MD-}2Y9pC~V(ROj&=sB}2@e!T=I zZ2PmVk53nISUc}8y|}bNS60h$F}I0AP)mweG@FklB8RUWtqT1Uj6;G1%^;O~5(ABn za=jk%u9g@*t)CQ}doM{;8;K`m1_DyXy$L1Z3N5l!Wpx7+6hv)d+w;s2e>o=!=Z~d%B6Wfe|))~H52@;O57~-IXoiBy4$`);6HiOS;#o>PREby zkoORj=ydZFDl4VfoHRWgOK**K2W#p1fw zTn9(xN|9Lq^$wHQvd<1A7$>yDtb7*DetHRO;``Z8eiHszevyM=m2b(9Rv3c2PF=en z)`id!-bf~9TfQ-jq#!)JnW9f#K5=>fx3F8l=W?lA*&ZN^Yi}nE@;ye@uxq+focZ$q zEJ5-;IyIIjFvTr#o>>4SctO=!FpdwY-fMYJKosHf>3oH=^?;|Rws9AVz2~TL*pmFT z@Zx43*k`nT`y%;O_Xiv=IY{v46ry+vH1X}7boT|vLIFn<%o?m4Zjhj9_5(m^#@B36 zwK67!Dk)i?N}orUrFaG^y^5CkJg(0z)veOGKw-*gIA>UwA!`^13B@pZV5Jxi-FHo) z<4EPB-r@Buo~@Q6Fi2LoalH8o?YsaLD~xSm3SFfXTCumj{G9zpDG7Bx z=)WMaG9wMkK_V@qzA*}JjFg!Qs{uQXv2mu&Mm4@bmGk8^kQY3Q*kHx+02x&Lab-Ac53}(|9BK!bq_tUQE}`wp!)ra+Z@J~ zcWS37rt0>7J6`8#ldP85pA{EoyN{u5jny`@JIwWo=N`3Fc*Q6se!e+dw{ z9wp;`PC-hyf18V0{vZ>M{sI)Q2=rZc%ON=w#&OMx{JR}BBpO=a8m4DZT&78G!H=fr z2P++`LhiA`;DS?@BM${O1fO9)ICW{jSy81Kzb;TVlI3G`rSy?nO8kA(@s%6EG5_+$ zm7^g^Gjf=ASk+)DPj*lXfV-SI9g*o2ZQuVQ+{iLow;aI4&<~tBl58zX8z3Eg6PE@p zP&*QHFC}6T#D3rVkn+ttHBaP zT+BZ!nAuLE52OPY*tQlY;-C6|Lu)oyDSkPmNj0m$3hR*k8)Yv{n!v8Zpi)4d8bVGi zUMGTpY2)xB$V7q5`QIWCQ4#a2nxR1~lt=>tJYUztZ5{s-XgtpAU_oLzgUENx%xIX} zoUNP`9N*}Hq-{}xXW_CmIA%#KDlfnE#n+%WP}^K^9v( zadEO05F7#l)IRf@FnZ~Lo2{_JY#4{GUNK0VA6VYt_FnAmOSaG=RSagG#cKQ)Od6CH zM;GETEp^w2

8>!gYp|LvOly{Y+R7sq$RgB@RW^+LdBlnrxS`f52SoTS3R?OBOjw zzL0>OTY9t8qZp|4cmtL{iUs1ys+WaiqECf{rMti`A6GY$A1XUMjf%U^fW| z-D97M9{5bzc6da~);Sa9sfmGN9VXS%zs;H_BUJWSYy zlmj$J^g4d=A6R|S{ST~Kuk8tuQ2hb&WZ{tX*&e8CMaDzi)yg(@aii>C9&1E+cdjqR zvVBvt*cX$%R3!DCsYWwUT#WLNB1EOd)~9D$acN=mRcP{CSB~NeD2fUJCDj+c=SY3F#j&9J%R3C> zzH;q*bmY0nn(MSuw%Q09rv?MMrT-(@h%c|4SLI0nR5{c3%H9*%GipqEX=K5^aF_AU zOivAsNYpw=N{ARX!$)(Pf~&U0*tg@e0gtgHS)^K=X~rcgYm25cX|je1#r$~XobBbE zI1HgK@7Tf{NNRD3Qk5*mLuzLFA212F?7aVMnm2o0hHI^L_7=J>Lq)8($B8eaL7rWr zMy=odct4gGNu&^xRSf9x`ZAGl@V=33cV1cZ;d`F^p9Y;v)m02S>iwUxEB;CPU{azv zp#8ma#QStq;t@zP<(XV6QvqTCzo;iQaX-wO_BG@?}^l*k(Jv0!0D@j@RreA56Qd{2{Ga% z39LY(Hk);xYSHni_V-zUIykDRoTB#H+&SwB0~C! z`NtmO&$L6q<&(_4n6V#sN@Aod?LN`?&l%1@LT?f*rPDtrc>3w)^%|!HPs~;_{jJ!~ zA)K~fmvfliGUy5BCI0nUri8f{#Cpwm;?Ns`dVyW0>7Sg3X_#!7$~_DU4W|``L*rw` zH#&BZ6gcU}Nl65nTr)-lzOUYFuuY=GBu|{zB!Q!5sD+!{Sf{UCO4`rQgc3OD*pVoM z9nQ8m#GE{&oMy7moeBL`RNeuxw$b~tm$Z^P=>@Rh`_BvmsHSD>M-x(y>OP#*Fw*0h zCoJ!G?9gsEhRj|25;(M!L0heV8>s(9>4KD>*Rpdhx$I3pm^WtX!tigQcs8b+3<7iK z{TPh>Qt28GtFEHH0hEh(f=*P7cuorbj~KLSqb?j_BHKX+c^!_(WEDh6sY0W){=0uA ztL>x=1=5&NoCS2Xqj7g?LofBzP&0@u)`3GF|4y9WjU_HUp#A`(h|F4MwdDMM@G|Q5$>RAaS{g4~-CQ zf|%d#M*N|~Uv{hTGBY{e^i1Q_n_(ZfQd;eyFy}v7ms#gJMaP*6A~JgOmyGX*l}oQZ z4X2)*(y;&T)e!s@3LQppDn2jIM^*R|-YpSFw+vhOyc^i|Kv=`_vH6ShD?>deluZ@K zG})MJD9mfBm|g=ozV+XIAXO0oIfDEd3C(BH-=4jf>uS}B?MmZ>)`=J>WYWx+|SiYsr~gb9falj^V!NiR9yb5D@bC{6+`k- zy&5Z^V}%@`tNzGyJUITU59(V<$^dqJTM|H<9E`U{X-VUIK`(22W5=f=w*F|JPp3FAdCLB@*?%G<0X5T1&3(#qyXNlD0bUQzF#|` zSHy$BSzvCQYKar{zfHA^erN#4w_zvH6X!Z-OA^ny>c)r~Huq{J z@#Yo#u8@rv+&otl(<-k0RNIQ~X$&Oax7!`W;;HCB^6Fq3n5^s|@G~+dSIkdYfu6Bo;b#&#;!MWKD+{*5G5h&?++Z8B_9tP?G6>m# z7Qu=0CTqlxv?maJS&b;!_aK_@XtXt4?Ujh$ps{;Q1%HC3DQmC+HI=e)S67vDJ zEFJC1NN04!Ov=Efq5W>5>NXF3FIN$jZZ0pVHf%utTIdP){a47d>jd06<5dy|+0fOK zvEdC$Wp{Cm*zqbeCabJZ-RkX9n8GTZGDybMfjPy`S^vD~ox1O=!*m?!lfwkYMT%_7i+wMB zS5IDhSGLW%9CUjw0(_$H1bYvSxQff54y&_z${ToEnt57@TDV)G8UP2t*Vj^n#aHl$Teqz^>r`5^!>{w6pg6{|hJ#OfjJZIR1|YPdg_o4^J~E*Z)_B zkND+C!_6R?dfCYZUyl1@!_^}cJQz;bG71jakt4m5~o5v1W=M!ht|rP GhW`&9@hcDj literal 0 HcmV?d00001 diff --git a/web/icon-512.png b/web/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..babcc308c70234e863539c00043fce1775d776ea GIT binary patch literal 22987 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 + + 🎙️ + \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..054ae48 --- /dev/null +++ b/web/index.html @@ -0,0 +1,175 @@ + + + + + + + + Edge TTS - Text to Speech + + + + + + +

+ + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..92bffe6 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,40 @@ +{ + "name": "Edge TTS", + "short_name": "Edge TTS", + "description": "Convert text to speech using Microsoft Edge's online TTS service", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#2563eb", + "orientation": "portrait-primary", + "icons": [ + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["productivity", "utilities"], + "screenshots": [ + { + "src": "screenshot.png", + "sizes": "1280x720", + "type": "image/png" + } + ], + "share_target": { + "action": "/", + "method": "GET", + "params": { + "title": "title", + "text": "text" + } + } +} diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..bd4c76b --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.5.0 +edge-tts>=7.0.0 diff --git a/web/server.py b/web/server.py new file mode 100755 index 0000000..2dae6aa --- /dev/null +++ b/web/server.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +Edge TTS Web API Server + +This server provides a REST API for the edge-tts web UI. +""" + +import asyncio +import io +import logging +from typing import Optional + +from fastapi import FastAPI, HTTPException, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +import uvicorn + +# Import edge_tts +import edge_tts +from edge_tts import VoicesManager + + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create FastAPI app +app = FastAPI( + title="Edge TTS API", + description="REST API for Microsoft Edge Text-to-Speech service", + version="1.0.0" +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Global voices cache +voices_cache: Optional[list] = None + + +# Models +class SynthesizeRequest(BaseModel): + text: str = Field(..., max_length=5000, description="Text to convert to speech") + voice: str = Field(default="en-US-EmmaMultilingualNeural", description="Voice name") + rate: str = Field(default="+0%", description="Speech rate (e.g., '+0%', '-50%', '+100%')") + volume: str = Field(default="+0%", description="Volume (e.g., '+0%', '-50%', '+100%')") + pitch: str = Field(default="+0Hz", description="Pitch (e.g., '+0Hz', '-500Hz', '+500Hz')") + + +class VoiceResponse(BaseModel): + Name: str + ShortName: str + Gender: str + Locale: str + LocaleName: str + LocalName: Optional[str] = None + DisplayName: Optional[str] = None + Status: Optional[str] = None + + +# API Routes +@app.get("/") +async def root(): + """Serve the main web page""" + return FileResponse("index.html") + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "service": "edge-tts-api"} + + +@app.get("/api/voices") +async def get_voices(): + """ + Get list of all available voices. + + Returns a list of voice objects with their properties. + """ + global voices_cache + + try: + # Use cached voices if available + if voices_cache is None: + logger.info("Fetching voices from Edge TTS service...") + voices_cache = await edge_tts.list_voices() + logger.info(f"Loaded {len(voices_cache)} voices") + + return voices_cache + + except Exception as e: + logger.error(f"Error fetching voices: {e}") + raise HTTPException(status_code=500, detail=f"Failed to fetch voices: {str(e)}") + + +@app.post("/api/synthesize") +async def synthesize_speech(request: SynthesizeRequest): + """ + Synthesize speech from text. + + Returns an MP3 audio file. + """ + try: + logger.info(f"Synthesizing speech: text_length={len(request.text)}, voice={request.voice}") + + # Validate text + if not request.text.strip(): + raise HTTPException(status_code=400, detail="Text cannot be empty") + + if len(request.text) > 5000: + raise HTTPException(status_code=400, detail="Text exceeds maximum length of 5000 characters") + + # Create Communicate instance + communicate = edge_tts.Communicate( + text=request.text, + voice=request.voice, + rate=request.rate, + volume=request.volume, + pitch=request.pitch + ) + + # Generate audio + audio_data = io.BytesIO() + + async for chunk in communicate.stream(): + if chunk["type"] == "audio": + audio_data.write(chunk["data"]) + + # Check if audio was generated + audio_data.seek(0) + if audio_data.getbuffer().nbytes == 0: + raise HTTPException(status_code=500, detail="No audio was generated") + + logger.info(f"Successfully generated {audio_data.getbuffer().nbytes} bytes of audio") + + # Return audio as MP3 + return Response( + content=audio_data.getvalue(), + media_type="audio/mpeg", + headers={ + "Content-Disposition": "attachment; filename=speech.mp3" + } + ) + + except edge_tts.exceptions.NoAudioReceived as e: + logger.error(f"No audio received: {e}") + raise HTTPException(status_code=400, detail="No audio was generated. Check your parameters.") + + except edge_tts.exceptions.UnknownResponse as e: + logger.error(f"Unknown response from TTS service: {e}") + raise HTTPException(status_code=502, detail="Unknown response from TTS service") + + except edge_tts.exceptions.WebSocketError as e: + logger.error(f"WebSocket error: {e}") + raise HTTPException(status_code=503, detail="Failed to connect to TTS service") + + except HTTPException: + raise + + except Exception as e: + logger.error(f"Error synthesizing speech: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to synthesize speech: {str(e)}") + + +@app.post("/api/synthesize-with-subtitles") +async def synthesize_with_subtitles(request: SynthesizeRequest): + """ + Synthesize speech from text and generate subtitles. + + Returns JSON with audio data (base64) and SRT subtitles. + """ + try: + logger.info(f"Synthesizing with subtitles: text_length={len(request.text)}, voice={request.voice}") + + # Validate text + if not request.text.strip(): + raise HTTPException(status_code=400, detail="Text cannot be empty") + + # Create Communicate instance + communicate = edge_tts.Communicate( + text=request.text, + voice=request.voice, + rate=request.rate, + volume=request.volume, + pitch=request.pitch + ) + + # Create subtitle maker + submaker = edge_tts.SubMaker() + + # Generate audio and subtitles + audio_data = io.BytesIO() + + async for chunk in communicate.stream(): + if chunk["type"] == "audio": + audio_data.write(chunk["data"]) + elif chunk["type"] in ("WordBoundary", "SentenceBoundary"): + submaker.feed(chunk) + + # Get subtitles + subtitles = submaker.get_srt() + + # Return both audio and subtitles + import base64 + audio_data.seek(0) + audio_base64 = base64.b64encode(audio_data.read()).decode('utf-8') + + return { + "audio": audio_base64, + "subtitles": subtitles, + "format": "mp3" + } + + except Exception as e: + logger.error(f"Error synthesizing with subtitles: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to synthesize: {str(e)}") + + +# Mount static files +app.mount("/", StaticFiles(directory=".", html=True), name="static") + + +def main(): + """Run the server""" + import argparse + + parser = argparse.ArgumentParser(description="Edge TTS Web API Server") + parser.add_argument("--host", default="0.0.0.0", help="Host to bind to") + parser.add_argument("--port", type=int, default=8000, help="Port to bind to") + parser.add_argument("--reload", action="store_true", help="Enable auto-reload") + + args = parser.parse_args() + + logger.info(f"Starting Edge TTS Web Server on {args.host}:{args.port}") + logger.info(f"Visit http://localhost:{args.port} to use the web interface") + + uvicorn.run( + "server:app", + host=args.host, + port=args.port, + reload=args.reload, + log_level="info" + ) + + +if __name__ == "__main__": + main() diff --git a/web/start.sh b/web/start.sh new file mode 100755 index 0000000..039039d --- /dev/null +++ b/web/start.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Quick start script for Edge TTS Web UI + +echo "🎙️ Starting Edge TTS Web UI..." +echo "" + +source ../.venv/bin/activate + +# Check if Python is installed +if ! command -v python3 &> /dev/null; then + echo "❌ Python 3 is not installed. Please install Python 3.8 or higher." + exit 1 +fi + +# Check if dependencies are installed +if ! python3 -c "import fastapi" 2>/dev/null; then + echo "📦 Installing dependencies..." + pip3 install -r requirements.txt + echo "" +fi + +# Start the server +echo "✅ Starting server on http://localhost:8000" +echo "" +echo "Press Ctrl+C to stop the server" +echo "" + +python3 server.py diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..3630ea3 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,488 @@ +:root { + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --secondary-color: #64748b; + --background: #f8fafc; + --card-background: #ffffff; + --text-primary: #1e293b; + --text-secondary: #64748b; + --border-color: #e2e8f0; + --success-color: #10b981; + --error-color: #ef4444; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: var(--background); + color: var(--text-primary); + line-height: 1.6; + padding: 1rem; +} + +.container { + max-width: 800px; + margin: 0 auto; +} + +/* Header */ +header { + text-align: center; + margin-bottom: 2rem; + padding: 2rem 0; +} + +header h1 { + font-size: 2.5rem; + color: var(--primary-color); + margin-bottom: 0.5rem; +} + +.subtitle { + color: var(--text-secondary); + font-size: 1.1rem; +} + +/* Card */ +.card { + background: var(--card-background); + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow); + border: 1px solid var(--border-color); +} + +.card h2 { + font-size: 1.25rem; + color: var(--text-primary); + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border-color); +} + +/* Form Elements */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; +} + +textarea { + width: 100%; + padding: 0.75rem; + border: 2px solid var(--border-color); + border-radius: 0.5rem; + font-family: inherit; + font-size: 1rem; + resize: vertical; + transition: border-color 0.2s; +} + +textarea:focus { + outline: none; + border-color: var(--primary-color); +} + +select { + width: 100%; + padding: 0.75rem; + border: 2px solid var(--border-color); + border-radius: 0.5rem; + font-family: inherit; + font-size: 1rem; + background: white; + cursor: pointer; + transition: border-color 0.2s; +} + +select:focus { + outline: none; + border-color: var(--primary-color); +} + +.char-count { + text-align: right; + color: var(--text-secondary); + font-size: 0.875rem; + margin-top: 0.5rem; +} + +/* Filters */ +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +/* Test Voice Section */ +.test-voice-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 1rem; +} + +.test-voice-hint { + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0; +} + +/* Prosody Controls */ +.prosody-controls { + margin-top: 1rem; +} + +input[type="range"] { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--border-color); + outline: none; + -webkit-appearance: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary-color); + cursor: pointer; + transition: background 0.2s; +} + +input[type="range"]::-webkit-slider-thumb:hover { + background: var(--primary-hover); +} + +input[type="range"]::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary-color); + cursor: pointer; + border: none; + transition: background 0.2s; +} + +input[type="range"]::-moz-range-thumb:hover { + background: var(--primary-hover); +} + +/* Buttons */ +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-family: inherit; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-lg); +} + +.btn-secondary { + background: var(--secondary-color); + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background: #475569; + transform: translateY(-1px); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-icon { + font-size: 1.2rem; +} + +.btn-link { + background: none; + border: none; + color: var(--primary-color); + cursor: pointer; + text-decoration: underline; + font-size: inherit; + padding: 0; +} + +.btn-link:hover { + color: var(--primary-hover); +} + +/* Actions */ +.actions { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +/* Progress Bar */ +.progress-bar { + width: 100%; + height: 4px; + background: var(--border-color); + border-radius: 2px; + overflow: hidden; + margin: 1rem 0; +} + +.progress-fill { + height: 100%; + background: var(--primary-color); + animation: progress 1.5s ease-in-out infinite; +} + +@keyframes progress { + 0% { + width: 0%; + margin-left: 0%; + } + 50% { + width: 50%; + margin-left: 25%; + } + 100% { + width: 0%; + margin-left: 100%; + } +} + +/* Status Message */ +.status-message { + padding: 0.75rem; + border-radius: 0.5rem; + margin-top: 1rem; + display: none; +} + +.status-message.success { + background: #d1fae5; + color: #065f46; + display: block; +} + +.status-message.error { + background: #fee2e2; + color: #991b1b; + display: block; +} + +.status-message.info { + background: #dbeafe; + color: #1e40af; + display: block; +} + +/* Audio Section */ +#audioPlayer { + width: 100%; + margin-bottom: 1rem; +} + +.audio-actions { + display: flex; + gap: 1rem; +} + +/* History */ +.history-list { + max-height: 300px; + overflow-y: auto; +} + +.history-item { + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 0.5rem; + margin-bottom: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.history-item:hover { + border-color: var(--primary-color); + background: #f1f5f9; +} + +.history-item-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 0.5rem; +} + +.history-item-text { + font-size: 0.9rem; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + margin-right: 1rem; +} + +.history-item-voice { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.history-item-time { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.history-item-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.history-item-actions button { + padding: 0.25rem 0.75rem; + font-size: 0.875rem; +} + +.empty-state { + text-align: center; + color: var(--text-secondary); + padding: 2rem; +} + +/* Footer */ +footer { + text-align: center; + padding: 2rem 0; + color: var(--text-secondary); + font-size: 0.875rem; +} + +footer a { + color: var(--primary-color); + text-decoration: none; +} + +footer a:hover { + text-decoration: underline; +} + +.status-indicator { + margin-top: 0.5rem; +} + +.online { + color: var(--success-color); +} + +.offline { + color: var(--error-color); +} + +/* Responsive */ +@media (max-width: 640px) { + body { + padding: 0.5rem; + } + + header h1 { + font-size: 2rem; + } + + .card { + padding: 1rem; + } + + .actions { + flex-direction: column; + } + + .actions .btn { + width: 100%; + } + + .filters { + grid-template-columns: 1fr; + } + + .test-voice-section { + flex-direction: column; + align-items: stretch; + } + + .test-voice-section .btn { + width: 100%; + } + + .test-voice-hint { + text-align: center; + } +} + +/* Install prompt animation */ +#installPrompt { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Loading animation */ +.loading { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..3470e87 --- /dev/null +++ b/web/sw.js @@ -0,0 +1,142 @@ +// Service Worker for Edge TTS PWA +const CACHE_NAME = 'edge-tts-v1'; +const urlsToCache = [ + '/', + '/index.html', + '/styles.css', + '/app.js', + '/manifest.json', + '/icon-192.png', + '/icon-512.png' +]; + +// Install event - cache resources +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => { + console.log('Opened cache'); + return cache.addAll(urlsToCache.map(url => { + // Try to add each URL, but don't fail if some are missing + return cache.add(url).catch(err => { + console.log('Failed to cache:', url, err); + }); + })); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + console.log('Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', (event) => { + const { request } = event; + + // Skip API requests - always go to network + if (request.url.includes('/api/')) { + event.respondWith( + fetch(request) + .catch(() => { + return new Response( + JSON.stringify({ error: 'Network unavailable' }), + { + status: 503, + headers: { 'Content-Type': 'application/json' } + } + ); + }) + ); + return; + } + + // Cache-first strategy for static assets + event.respondWith( + caches.match(request) + .then((response) => { + // Cache hit - return response + if (response) { + return response; + } + + // Clone the request + const fetchRequest = request.clone(); + + return fetch(fetchRequest).then((response) => { + // Check if valid response + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + // Clone the response + const responseToCache = response.clone(); + + // Cache the new resource + caches.open(CACHE_NAME) + .then((cache) => { + cache.put(request, responseToCache); + }); + + return response; + }); + }) + .catch(() => { + // Return offline page or error + return new Response('Offline', { + status: 503, + statusText: 'Service Unavailable' + }); + }) + ); +}); + +// Background sync for offline TTS generation (future enhancement) +self.addEventListener('sync', (event) => { + if (event.tag === 'sync-tts') { + event.waitUntil(syncTTS()); + } +}); + +async function syncTTS() { + // Placeholder for future offline TTS queue functionality + console.log('Syncing pending TTS requests...'); +} + +// Push notifications (future enhancement) +self.addEventListener('push', (event) => { + const data = event.data.json(); + + const options = { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + vibrate: [200, 100, 200] + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +// Notification click handler +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + clients.openWindow('/') + ); +});