Spaces:
Running
Running
Upload 26 files
Browse files- .env.example +10 -0
- .github/ISSUE_TEMPLATE/bug_report.md +38 -0
- .github/ISSUE_TEMPLATE/feature_request.md +20 -0
- .github/workflows/docker-build.yml +78 -0
- .github/workflows/release.yml +90 -0
- .gitignore +156 -0
- CHANGELOG.md +191 -0
- Dockerfile +34 -0
- LICENSE +21 -0
- pyproject.toml +161 -0
- requirements.txt +4 -0
- ttsfm-web/app.py +574 -0
- ttsfm-web/requirements.txt +9 -0
- ttsfm-web/static/css/style.css +1390 -0
- ttsfm-web/static/js/playground.js +745 -0
- ttsfm-web/templates/base.html +349 -0
- ttsfm-web/templates/docs.html +369 -0
- ttsfm-web/templates/index.html +146 -0
- ttsfm-web/templates/playground.html +295 -0
- ttsfm/__init__.py +183 -0
- ttsfm/async_client.py +464 -0
- ttsfm/cli.py +362 -0
- ttsfm/client.py +481 -0
- ttsfm/exceptions.py +243 -0
- ttsfm/models.py +283 -0
- ttsfm/utils.py +421 -0
.env.example
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Server configuration
|
| 2 |
+
HOST=0.0.0.0
|
| 3 |
+
PORT=7000
|
| 4 |
+
|
| 5 |
+
# SSL configuration
|
| 6 |
+
VERIFY_SSL=true
|
| 7 |
+
|
| 8 |
+
# Flask configuration
|
| 9 |
+
FLASK_ENV=production
|
| 10 |
+
FLASK_APP=app.py
|
.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug report
|
| 3 |
+
about: Create a report to help us improve
|
| 4 |
+
title: ''
|
| 5 |
+
labels: ''
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
**Describe the bug**
|
| 11 |
+
A clear and concise description of what the bug is.
|
| 12 |
+
|
| 13 |
+
**To Reproduce**
|
| 14 |
+
Steps to reproduce the behavior:
|
| 15 |
+
1. Go to '...'
|
| 16 |
+
2. Click on '....'
|
| 17 |
+
3. Scroll down to '....'
|
| 18 |
+
4. See error
|
| 19 |
+
|
| 20 |
+
**Expected behavior**
|
| 21 |
+
A clear and concise description of what you expected to happen.
|
| 22 |
+
|
| 23 |
+
**Screenshots**
|
| 24 |
+
If applicable, add screenshots to help explain your problem.
|
| 25 |
+
|
| 26 |
+
**Desktop (please complete the following information):**
|
| 27 |
+
- OS: [e.g. iOS]
|
| 28 |
+
- Browser [e.g. chrome, safari]
|
| 29 |
+
- Version [e.g. 22]
|
| 30 |
+
|
| 31 |
+
**Smartphone (please complete the following information):**
|
| 32 |
+
- Device: [e.g. iPhone6]
|
| 33 |
+
- OS: [e.g. iOS8.1]
|
| 34 |
+
- Browser [e.g. stock browser, safari]
|
| 35 |
+
- Version [e.g. 22]
|
| 36 |
+
|
| 37 |
+
**Additional context**
|
| 38 |
+
Add any other context about the problem here.
|
.github/ISSUE_TEMPLATE/feature_request.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Feature request
|
| 3 |
+
about: Suggest an idea for this project
|
| 4 |
+
title: ''
|
| 5 |
+
labels: ''
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
**Is your feature request related to a problem? Please describe.**
|
| 11 |
+
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
| 12 |
+
|
| 13 |
+
**Describe the solution you'd like**
|
| 14 |
+
A clear and concise description of what you want to happen.
|
| 15 |
+
|
| 16 |
+
**Describe alternatives you've considered**
|
| 17 |
+
A clear and concise description of any alternative solutions or features you've considered.
|
| 18 |
+
|
| 19 |
+
**Additional context**
|
| 20 |
+
Add any other context or screenshots about the feature request here.
|
.github/workflows/docker-build.yml
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Docker Build and Push
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
release:
|
| 5 |
+
types: [published]
|
| 6 |
+
|
| 7 |
+
env:
|
| 8 |
+
REGISTRY_DOCKERHUB: docker.io
|
| 9 |
+
REGISTRY_GHCR: ghcr.io
|
| 10 |
+
IMAGE_NAME: ${{ github.repository }}
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
build-and-push:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
permissions:
|
| 16 |
+
contents: read
|
| 17 |
+
packages: write
|
| 18 |
+
steps:
|
| 19 |
+
- name: Checkout repository
|
| 20 |
+
uses: actions/checkout@v4
|
| 21 |
+
|
| 22 |
+
- name: Set up QEMU
|
| 23 |
+
uses: docker/setup-qemu-action@v3
|
| 24 |
+
|
| 25 |
+
- name: Set up Docker Buildx
|
| 26 |
+
uses: docker/setup-buildx-action@v3
|
| 27 |
+
with:
|
| 28 |
+
driver: docker-container
|
| 29 |
+
|
| 30 |
+
- name: Login to Docker Hub
|
| 31 |
+
uses: docker/login-action@v3
|
| 32 |
+
with:
|
| 33 |
+
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
| 34 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
| 35 |
+
|
| 36 |
+
- name: Login to GitHub Container Registry
|
| 37 |
+
uses: docker/login-action@v3
|
| 38 |
+
with:
|
| 39 |
+
registry: ${{ env.REGISTRY_GHCR }}
|
| 40 |
+
username: ${{ github.actor }}
|
| 41 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 42 |
+
|
| 43 |
+
- name: Extract metadata
|
| 44 |
+
id: meta
|
| 45 |
+
uses: docker/metadata-action@v5
|
| 46 |
+
with:
|
| 47 |
+
images: |
|
| 48 |
+
${{ secrets.DOCKERHUB_USERNAME }}/ttsfm
|
| 49 |
+
${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}
|
| 50 |
+
tags: |
|
| 51 |
+
type=ref,event=tag
|
| 52 |
+
type=semver,pattern={{version}}
|
| 53 |
+
type=semver,pattern={{major}}.{{minor}}
|
| 54 |
+
type=semver,pattern={{major}}
|
| 55 |
+
type=raw,value=latest
|
| 56 |
+
labels: |
|
| 57 |
+
org.opencontainers.image.source=${{ github.repositoryUrl }}
|
| 58 |
+
org.opencontainers.image.description=Free TTS API server compatible with OpenAI's TTS API format using openai.fm
|
| 59 |
+
org.opencontainers.image.licenses=MIT
|
| 60 |
+
org.opencontainers.image.title=TTSFM - Free TTS API Server
|
| 61 |
+
org.opencontainers.image.vendor=dbcccc
|
| 62 |
+
|
| 63 |
+
- name: Build and push
|
| 64 |
+
id: build-and-push
|
| 65 |
+
uses: docker/build-push-action@v5
|
| 66 |
+
with:
|
| 67 |
+
context: .
|
| 68 |
+
platforms: linux/amd64,linux/arm64
|
| 69 |
+
push: true
|
| 70 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 71 |
+
labels: ${{ steps.meta.outputs.labels }}
|
| 72 |
+
cache-from: type=gha
|
| 73 |
+
cache-to: type=gha,mode=max
|
| 74 |
+
|
| 75 |
+
- name: Show image info
|
| 76 |
+
run: |
|
| 77 |
+
echo "Pushed tags: ${{ steps.meta.outputs.tags }}"
|
| 78 |
+
echo "Image digest: ${{ steps.build-and-push.outputs.digest }}"
|
.github/workflows/release.yml
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Release and Publish
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- 'v*' # Triggers on version tags like v1.0.0, v3.0.1, etc.
|
| 7 |
+
|
| 8 |
+
permissions:
|
| 9 |
+
contents: write
|
| 10 |
+
id-token: write
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
release-and-publish:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
|
| 16 |
+
steps:
|
| 17 |
+
- uses: actions/checkout@v4
|
| 18 |
+
|
| 19 |
+
- name: Set up Python
|
| 20 |
+
uses: actions/setup-python@v4
|
| 21 |
+
with:
|
| 22 |
+
python-version: '3.11'
|
| 23 |
+
|
| 24 |
+
- name: Install dependencies
|
| 25 |
+
run: |
|
| 26 |
+
python -m pip install --upgrade pip
|
| 27 |
+
pip install build twine
|
| 28 |
+
|
| 29 |
+
- name: Test package import
|
| 30 |
+
run: |
|
| 31 |
+
pip install -e .
|
| 32 |
+
python -c "import ttsfm; print(f'✅ TTSFM imported successfully')"
|
| 33 |
+
python -c "from ttsfm import TTSClient; print('✅ TTSClient imported successfully')"
|
| 34 |
+
|
| 35 |
+
- name: Build package
|
| 36 |
+
run: |
|
| 37 |
+
python -m build
|
| 38 |
+
echo "📦 Package built successfully"
|
| 39 |
+
ls -la dist/
|
| 40 |
+
|
| 41 |
+
- name: Check package
|
| 42 |
+
run: |
|
| 43 |
+
twine check dist/*
|
| 44 |
+
echo "✅ Package validation passed"
|
| 45 |
+
|
| 46 |
+
- name: Publish to PyPI
|
| 47 |
+
uses: pypa/gh-action-pypi-publish@release/v1
|
| 48 |
+
with:
|
| 49 |
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
| 50 |
+
|
| 51 |
+
- name: Create GitHub Release
|
| 52 |
+
uses: softprops/action-gh-release@v1
|
| 53 |
+
with:
|
| 54 |
+
body: |
|
| 55 |
+
## 🎉 TTSFM ${{ github.ref_name }}
|
| 56 |
+
|
| 57 |
+
New release of TTSFM - Free Text-to-Speech API with OpenAI compatibility.
|
| 58 |
+
|
| 59 |
+
### 📦 Installation
|
| 60 |
+
```bash
|
| 61 |
+
pip install ttsfm==${{ github.ref_name }}
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### 🚀 Quick Start
|
| 65 |
+
```python
|
| 66 |
+
from ttsfm import TTSClient
|
| 67 |
+
|
| 68 |
+
client = TTSClient()
|
| 69 |
+
response = client.generate_speech("Hello from TTSFM!")
|
| 70 |
+
response.save_to_file("hello")
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### 🐳 Docker
|
| 74 |
+
```bash
|
| 75 |
+
docker run -p 8000:8000 dbcccc/ttsfm:latest
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### ✨ Features
|
| 79 |
+
- 🆓 Completely free (uses openai.fm service)
|
| 80 |
+
- 🎯 OpenAI-compatible API
|
| 81 |
+
- 🗣️ 11 voices available
|
| 82 |
+
- 🎵 6 audio formats (MP3, WAV, OPUS, AAC, FLAC, PCM)
|
| 83 |
+
- ⚡ Async and sync clients
|
| 84 |
+
- 🌐 Web interface included
|
| 85 |
+
- 🔧 CLI tool available
|
| 86 |
+
|
| 87 |
+
### 📚 Documentation
|
| 88 |
+
See [README](https://github.com/dbccccccc/ttsfm#readme) for full documentation.
|
| 89 |
+
draft: false
|
| 90 |
+
prerelease: false
|
.gitignore
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
MANIFEST
|
| 23 |
+
|
| 24 |
+
# Virtual Environment
|
| 25 |
+
venv/
|
| 26 |
+
env/
|
| 27 |
+
ENV/
|
| 28 |
+
.venv/
|
| 29 |
+
|
| 30 |
+
# Environment variables
|
| 31 |
+
.env
|
| 32 |
+
.env.local
|
| 33 |
+
.env.production
|
| 34 |
+
|
| 35 |
+
# IDE
|
| 36 |
+
.idea/
|
| 37 |
+
.vscode/
|
| 38 |
+
*.swp
|
| 39 |
+
*.swo
|
| 40 |
+
.spyderproject
|
| 41 |
+
.spyproject
|
| 42 |
+
|
| 43 |
+
# OS
|
| 44 |
+
.DS_Store
|
| 45 |
+
.DS_Store?
|
| 46 |
+
._*
|
| 47 |
+
.Spotlight-V100
|
| 48 |
+
.Trashes
|
| 49 |
+
ehthumbs.db
|
| 50 |
+
Thumbs.db
|
| 51 |
+
|
| 52 |
+
# Generated audio files (for testing)
|
| 53 |
+
*.mp3
|
| 54 |
+
*.wav
|
| 55 |
+
*.opus
|
| 56 |
+
*.aac
|
| 57 |
+
*.flac
|
| 58 |
+
*.pcm
|
| 59 |
+
test_output.*
|
| 60 |
+
output.*
|
| 61 |
+
hello.*
|
| 62 |
+
speech.*
|
| 63 |
+
|
| 64 |
+
# Logs
|
| 65 |
+
*.log
|
| 66 |
+
logs/
|
| 67 |
+
.pytest_cache/
|
| 68 |
+
|
| 69 |
+
# Temporary files
|
| 70 |
+
tmp/
|
| 71 |
+
temp/
|
| 72 |
+
.tmp/
|
| 73 |
+
|
| 74 |
+
# Coverage reports
|
| 75 |
+
htmlcov/
|
| 76 |
+
.coverage
|
| 77 |
+
.coverage.*
|
| 78 |
+
coverage.xml
|
| 79 |
+
*.cover
|
| 80 |
+
.hypothesis/
|
| 81 |
+
|
| 82 |
+
# Documentation builds
|
| 83 |
+
docs/_build/
|
| 84 |
+
site/
|
| 85 |
+
|
| 86 |
+
# Package builds
|
| 87 |
+
*.tar.gz
|
| 88 |
+
*.whl
|
| 89 |
+
dist/
|
| 90 |
+
build/
|
| 91 |
+
|
| 92 |
+
# MyPy
|
| 93 |
+
.mypy_cache/
|
| 94 |
+
.dmypy.json
|
| 95 |
+
dmypy.json
|
| 96 |
+
|
| 97 |
+
# Jupyter Notebook
|
| 98 |
+
.ipynb_checkpoints
|
| 99 |
+
|
| 100 |
+
# pyenv
|
| 101 |
+
.python-version
|
| 102 |
+
|
| 103 |
+
# pipenv
|
| 104 |
+
Pipfile.lock
|
| 105 |
+
|
| 106 |
+
# PEP 582
|
| 107 |
+
__pypackages__/
|
| 108 |
+
|
| 109 |
+
# Celery
|
| 110 |
+
celerybeat-schedule
|
| 111 |
+
celerybeat.pid
|
| 112 |
+
|
| 113 |
+
# SageMath parsed files
|
| 114 |
+
*.sage.py
|
| 115 |
+
|
| 116 |
+
# Rope project settings
|
| 117 |
+
.ropeproject
|
| 118 |
+
|
| 119 |
+
# mkdocs documentation
|
| 120 |
+
/site
|
| 121 |
+
|
| 122 |
+
# Pyre type checker
|
| 123 |
+
.pyre/
|
| 124 |
+
|
| 125 |
+
# Additional exclusions for GitHub
|
| 126 |
+
|
| 127 |
+
# API Keys and Secrets
|
| 128 |
+
config.json
|
| 129 |
+
secrets.json
|
| 130 |
+
.secrets
|
| 131 |
+
api_keys.txt
|
| 132 |
+
|
| 133 |
+
# Database files
|
| 134 |
+
*.db
|
| 135 |
+
*.sqlite
|
| 136 |
+
*.sqlite3
|
| 137 |
+
|
| 138 |
+
# Backup files
|
| 139 |
+
*.bak
|
| 140 |
+
*.backup
|
| 141 |
+
*~
|
| 142 |
+
|
| 143 |
+
# Node.js (if using any JS tools)
|
| 144 |
+
node_modules/
|
| 145 |
+
npm-debug.log*
|
| 146 |
+
yarn-debug.log*
|
| 147 |
+
yarn-error.log*
|
| 148 |
+
|
| 149 |
+
# Docker
|
| 150 |
+
.dockerignore
|
| 151 |
+
Dockerfile.dev
|
| 152 |
+
docker-compose.override.yml
|
| 153 |
+
|
| 154 |
+
# Local configuration
|
| 155 |
+
local_settings.py
|
| 156 |
+
local_config.py
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to this project will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
| 6 |
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 7 |
+
|
| 8 |
+
## [3.1.0] - 2024-12-19
|
| 9 |
+
|
| 10 |
+
### 🔧 Format Support Improvements
|
| 11 |
+
|
| 12 |
+
This release focuses on fixing audio format handling and improving format delivery optimization.
|
| 13 |
+
|
| 14 |
+
### ✨ Added
|
| 15 |
+
|
| 16 |
+
- **Smart Header Selection**: Intelligent HTTP header selection to optimize format delivery from openai.fm service
|
| 17 |
+
- **Format Mapping Functions**: Helper functions for better format handling and optimization
|
| 18 |
+
- **Enhanced Web Interface**: Improved format selection with detailed descriptions for each format
|
| 19 |
+
- **Comprehensive Format Documentation**: Updated README and documentation with complete format information
|
| 20 |
+
|
| 21 |
+
### 🔄 Changed
|
| 22 |
+
|
| 23 |
+
- **File Naming Logic**: Files are now saved with extensions based on the actual returned format, not the requested format
|
| 24 |
+
- **Enhanced Logging**: Added format-specific log messages for better debugging
|
| 25 |
+
- **Web API Enhancement**: `/api/formats` endpoint now provides detailed information about all supported formats
|
| 26 |
+
- **Documentation Updates**: README and package documentation now include comprehensive format guides
|
| 27 |
+
|
| 28 |
+
### 🐛 Fixed
|
| 29 |
+
|
| 30 |
+
- **MAJOR FIX**: Resolved file naming issue where files were saved with incorrect double extensions (e.g., `test.wav.mp3`, `test.opus.wav`)
|
| 31 |
+
- **Correct File Extensions**: Files now save with proper single extensions based on actual audio format (e.g., `test.mp3`, `test.wav`)
|
| 32 |
+
- **Format Optimization**: Improved format delivery through smart request optimization
|
| 33 |
+
- **Format Handling**: Better handling of all supported audio formats
|
| 34 |
+
|
| 35 |
+
### 📝 Technical Details
|
| 36 |
+
|
| 37 |
+
- **Format Optimization**: Smart request optimization to deliver the best quality for each format
|
| 38 |
+
- **Backward Compatibility**: Existing code continues to work unchanged
|
| 39 |
+
- **Enhanced Format Support**: Improved support for all 6 audio formats (MP3, WAV, OPUS, AAC, FLAC, PCM)
|
| 40 |
+
|
| 41 |
+
## [3.0.0] - 2025-06-06
|
| 42 |
+
|
| 43 |
+
### 🎉 First Python Package Release
|
| 44 |
+
|
| 45 |
+
This is the first release of TTSFM as an installable Python package. Previous versions (v1.x and v2.x) were service-only releases that provided the API server but not a pip-installable package.
|
| 46 |
+
|
| 47 |
+
### ✨ Added
|
| 48 |
+
|
| 49 |
+
- **Complete Package Restructure**: Modern Python package structure with proper typing
|
| 50 |
+
- **Async Support**: Full asynchronous client implementation with `asyncio`
|
| 51 |
+
- **OpenAI API Compatibility**: Drop-in replacement for OpenAI TTS API
|
| 52 |
+
- **Type Hints**: Complete type annotation support throughout the codebase
|
| 53 |
+
- **CLI Interface**: Command-line tool for easy TTS generation
|
| 54 |
+
- **Web Application**: Optional Flask-based web interface
|
| 55 |
+
- **Docker Support**: Multi-architecture Docker images (linux/amd64, linux/arm64)
|
| 56 |
+
- **Comprehensive Error Handling**: Detailed exception hierarchy
|
| 57 |
+
- **Multiple Audio Formats**: Support for MP3, WAV, FLAC, and more
|
| 58 |
+
- **Voice Options**: Multiple voice models (alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer)
|
| 59 |
+
- **Text Processing**: Automatic text length validation and splitting
|
| 60 |
+
- **Rate Limiting**: Built-in rate limiting and retry mechanisms
|
| 61 |
+
- **Configuration**: Environment variable and configuration file support
|
| 62 |
+
|
| 63 |
+
### 🔧 Technical Improvements
|
| 64 |
+
|
| 65 |
+
- **Modern Build System**: Using `pyproject.toml` with setuptools
|
| 66 |
+
- **GitHub Actions**: Automated Docker builds and PyPI publishing
|
| 67 |
+
- **Development Tools**: Pre-commit hooks, linting, testing setup
|
| 68 |
+
- **Documentation**: Comprehensive README and inline documentation
|
| 69 |
+
- **Package Management**: Proper dependency management with optional extras
|
| 70 |
+
|
| 71 |
+
### 🌐 API Changes
|
| 72 |
+
|
| 73 |
+
- **Breaking**: Complete API redesign for better usability
|
| 74 |
+
- **OpenAI Compatible**: `/v1/audio/speech` endpoint compatibility
|
| 75 |
+
- **RESTful Design**: Clean REST API design
|
| 76 |
+
- **Health Checks**: Built-in health check endpoints
|
| 77 |
+
- **CORS Support**: Cross-origin resource sharing enabled
|
| 78 |
+
|
| 79 |
+
### 📦 Installation Options
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
# Basic installation
|
| 83 |
+
pip install ttsfm
|
| 84 |
+
|
| 85 |
+
# With web application support
|
| 86 |
+
pip install ttsfm[web]
|
| 87 |
+
|
| 88 |
+
# With development tools
|
| 89 |
+
pip install ttsfm[dev]
|
| 90 |
+
|
| 91 |
+
# Docker
|
| 92 |
+
docker run -p 8000:8000 ghcr.io/dbccccccc/ttsfm:latest
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### 🚀 Quick Start
|
| 96 |
+
|
| 97 |
+
```python
|
| 98 |
+
from ttsfm import TTSClient, Voice
|
| 99 |
+
|
| 100 |
+
client = TTSClient()
|
| 101 |
+
response = client.generate_speech(
|
| 102 |
+
text="Hello! This is TTSFM v3.0.0",
|
| 103 |
+
voice=Voice.CORAL
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
with open("speech.mp3", "wb") as f:
|
| 107 |
+
f.write(response.audio_data)
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### 📦 Package vs Service History
|
| 111 |
+
|
| 112 |
+
**Important Note**: This v3.0.0 is the first release of TTSFM as a Python package available on PyPI. Previous versions (v1.x and v2.x) were service/API server releases only and were not available as installable packages.
|
| 113 |
+
|
| 114 |
+
- **v1.x - v2.x**: Service releases (API server only, not pip-installable)
|
| 115 |
+
- **v3.0.0+**: Full Python package releases (pip-installable with service capabilities)
|
| 116 |
+
|
| 117 |
+
### 🐛 Bug Fixes
|
| 118 |
+
|
| 119 |
+
- Fixed Docker build issues with dependency resolution
|
| 120 |
+
- Improved error handling and user feedback
|
| 121 |
+
- Better handling of long text inputs
|
| 122 |
+
- Enhanced stability and performance
|
| 123 |
+
|
| 124 |
+
### 📚 Documentation
|
| 125 |
+
|
| 126 |
+
- Complete API documentation
|
| 127 |
+
- Usage examples and tutorials
|
| 128 |
+
- Docker deployment guide
|
| 129 |
+
- Development setup instructions
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## Previous Service Releases (Not Available as Python Packages)
|
| 134 |
+
|
| 135 |
+
The following versions were service/API server releases only and were not available as pip-installable packages:
|
| 136 |
+
|
| 137 |
+
### [2.0.0-alpha9] - 2025-04-09
|
| 138 |
+
- Service improvements (alpha release)
|
| 139 |
+
|
| 140 |
+
### [2.0.0-alpha8] - 2025-04-09
|
| 141 |
+
- Service improvements (alpha release)
|
| 142 |
+
|
| 143 |
+
### [2.0.0-alpha7] - 2025-04-07
|
| 144 |
+
- Service improvements (alpha release)
|
| 145 |
+
|
| 146 |
+
### [2.0.0-alpha6] - 2025-04-07
|
| 147 |
+
- Service improvements (alpha release)
|
| 148 |
+
|
| 149 |
+
### [2.0.0-alpha5] - 2025-04-07
|
| 150 |
+
- Service improvements (alpha release)
|
| 151 |
+
|
| 152 |
+
### [2.0.0-alpha4] - 2025-04-07
|
| 153 |
+
- Service improvements (alpha release)
|
| 154 |
+
|
| 155 |
+
### [2.0.0-alpha3] - 2025-04-07
|
| 156 |
+
- Service improvements (alpha release)
|
| 157 |
+
|
| 158 |
+
### [2.0.0-alpha2] - 2025-04-07
|
| 159 |
+
- Service improvements (alpha release)
|
| 160 |
+
|
| 161 |
+
### [2.0.0-alpha1] - 2025-04-07
|
| 162 |
+
- Alpha release (DO NOT USE)
|
| 163 |
+
|
| 164 |
+
### [1.3.0] - 2025-03-28
|
| 165 |
+
- Support for additional audio file formats in the API
|
| 166 |
+
- Alignment with formats supported by the official API
|
| 167 |
+
|
| 168 |
+
### [1.2.2] - 2025-03-28
|
| 169 |
+
- Fixed Docker support
|
| 170 |
+
|
| 171 |
+
### [1.2.1] - 2025-03-28
|
| 172 |
+
- Color change for indicator for status
|
| 173 |
+
- Voice preview on webpage for each voice
|
| 174 |
+
|
| 175 |
+
### [1.2.0] - 2025-03-26
|
| 176 |
+
- Enhanced stability and availability by implementing advanced request handling mechanisms
|
| 177 |
+
- Removed the proxy pool
|
| 178 |
+
|
| 179 |
+
### [1.1.2] - 2025-03-26
|
| 180 |
+
- Version display on webpage
|
| 181 |
+
- Last version of 1.1.x
|
| 182 |
+
|
| 183 |
+
### [1.1.1] - 2025-03-26
|
| 184 |
+
- Build fixes
|
| 185 |
+
|
| 186 |
+
### [1.1.0] - 2025-03-26
|
| 187 |
+
- Project restructuring for better future development experiences
|
| 188 |
+
- Added .env settings
|
| 189 |
+
|
| 190 |
+
### [1.0.0] - 2025-03-26
|
| 191 |
+
- First service release
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PYTHONUNBUFFERED=1 \
|
| 7 |
+
PORT=8000
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
RUN apt-get update && apt-get install -y gcc curl && rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy source code first
|
| 13 |
+
COPY ttsfm/ ./ttsfm/
|
| 14 |
+
COPY ttsfm-web/ ./ttsfm-web/
|
| 15 |
+
COPY pyproject.toml ./
|
| 16 |
+
COPY requirements.txt ./
|
| 17 |
+
|
| 18 |
+
# Install the TTSFM package with web dependencies
|
| 19 |
+
RUN pip install --no-cache-dir -e .[web]
|
| 20 |
+
|
| 21 |
+
# Install additional web dependencies
|
| 22 |
+
RUN pip install --no-cache-dir python-dotenv>=1.0.0
|
| 23 |
+
|
| 24 |
+
# Create non-root user
|
| 25 |
+
RUN useradd --create-home ttsfm && chown -R ttsfm:ttsfm /app
|
| 26 |
+
USER ttsfm
|
| 27 |
+
|
| 28 |
+
EXPOSE 8000
|
| 29 |
+
|
| 30 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 31 |
+
CMD curl -f http://localhost:8000/api/health || exit 1
|
| 32 |
+
|
| 33 |
+
WORKDIR /app/ttsfm-web
|
| 34 |
+
CMD ["python", "-m", "waitress", "--host=0.0.0.0", "--port=8000", "app:app"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 dbcccc
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
pyproject.toml
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "ttsfm"
|
| 7 |
+
version = "3.1.0"
|
| 8 |
+
description = "Text-to-Speech API Client with OpenAI compatibility"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = "MIT"
|
| 11 |
+
authors = [
|
| 12 |
+
{name = "dbcccc", email = "[email protected]"}
|
| 13 |
+
]
|
| 14 |
+
maintainers = [
|
| 15 |
+
{name = "dbcccc", email = "[email protected]"}
|
| 16 |
+
]
|
| 17 |
+
classifiers = [
|
| 18 |
+
"Development Status :: 4 - Beta",
|
| 19 |
+
"Intended Audience :: Developers",
|
| 20 |
+
|
| 21 |
+
"Operating System :: OS Independent",
|
| 22 |
+
"Programming Language :: Python :: 3",
|
| 23 |
+
"Programming Language :: Python :: 3.8",
|
| 24 |
+
"Programming Language :: Python :: 3.9",
|
| 25 |
+
"Programming Language :: Python :: 3.10",
|
| 26 |
+
"Programming Language :: Python :: 3.11",
|
| 27 |
+
"Programming Language :: Python :: 3.12",
|
| 28 |
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
| 29 |
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
| 30 |
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
| 31 |
+
]
|
| 32 |
+
keywords = [
|
| 33 |
+
"tts",
|
| 34 |
+
"text-to-speech",
|
| 35 |
+
"speech-synthesis",
|
| 36 |
+
"openai",
|
| 37 |
+
"api-client",
|
| 38 |
+
"audio",
|
| 39 |
+
"voice",
|
| 40 |
+
"speech"
|
| 41 |
+
]
|
| 42 |
+
requires-python = ">=3.8"
|
| 43 |
+
dependencies = [
|
| 44 |
+
"requests>=2.25.0",
|
| 45 |
+
"aiohttp>=3.8.0",
|
| 46 |
+
"fake-useragent>=1.4.0",
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
[project.optional-dependencies]
|
| 50 |
+
dev = [
|
| 51 |
+
"pytest>=6.0",
|
| 52 |
+
"pytest-asyncio>=0.18.0",
|
| 53 |
+
"pytest-cov>=2.0",
|
| 54 |
+
"black>=22.0",
|
| 55 |
+
"isort>=5.0",
|
| 56 |
+
"flake8>=4.0",
|
| 57 |
+
"mypy>=0.900",
|
| 58 |
+
"pre-commit>=2.0",
|
| 59 |
+
]
|
| 60 |
+
docs = [
|
| 61 |
+
"sphinx>=4.0",
|
| 62 |
+
"sphinx-rtd-theme>=1.0",
|
| 63 |
+
"myst-parser>=0.17",
|
| 64 |
+
]
|
| 65 |
+
web = [
|
| 66 |
+
"flask>=2.0.0",
|
| 67 |
+
"flask-cors>=3.0.10",
|
| 68 |
+
"waitress>=3.0.0",
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
[project.urls]
|
| 72 |
+
Homepage = "https://github.com/dbccccccc/ttsfm"
|
| 73 |
+
Documentation = "https://github.com/dbccccccc/ttsfm/blob/main/docs/"
|
| 74 |
+
Repository = "https://github.com/dbccccccc/ttsfm"
|
| 75 |
+
"Bug Tracker" = "https://github.com/dbccccccc/ttsfm/issues"
|
| 76 |
+
|
| 77 |
+
[project.scripts]
|
| 78 |
+
ttsfm = "ttsfm.cli:main"
|
| 79 |
+
|
| 80 |
+
[tool.setuptools]
|
| 81 |
+
packages = ["ttsfm"]
|
| 82 |
+
|
| 83 |
+
[tool.setuptools.package-data]
|
| 84 |
+
ttsfm = ["py.typed"]
|
| 85 |
+
|
| 86 |
+
[tool.black]
|
| 87 |
+
line-length = 100
|
| 88 |
+
target-version = ['py38']
|
| 89 |
+
include = '\.pyi?$'
|
| 90 |
+
extend-exclude = '''
|
| 91 |
+
/(
|
| 92 |
+
# directories
|
| 93 |
+
\.eggs
|
| 94 |
+
| \.git
|
| 95 |
+
| \.hg
|
| 96 |
+
| \.mypy_cache
|
| 97 |
+
| \.tox
|
| 98 |
+
| \.venv
|
| 99 |
+
| build
|
| 100 |
+
| dist
|
| 101 |
+
)/
|
| 102 |
+
'''
|
| 103 |
+
|
| 104 |
+
[tool.isort]
|
| 105 |
+
profile = "black"
|
| 106 |
+
line_length = 100
|
| 107 |
+
multi_line_output = 3
|
| 108 |
+
include_trailing_comma = true
|
| 109 |
+
force_grid_wrap = 0
|
| 110 |
+
use_parentheses = true
|
| 111 |
+
ensure_newline_before_comments = true
|
| 112 |
+
|
| 113 |
+
[tool.mypy]
|
| 114 |
+
python_version = "3.8"
|
| 115 |
+
warn_return_any = true
|
| 116 |
+
warn_unused_configs = true
|
| 117 |
+
disallow_untyped_defs = true
|
| 118 |
+
disallow_incomplete_defs = true
|
| 119 |
+
check_untyped_defs = true
|
| 120 |
+
disallow_untyped_decorators = true
|
| 121 |
+
no_implicit_optional = true
|
| 122 |
+
warn_redundant_casts = true
|
| 123 |
+
warn_unused_ignores = true
|
| 124 |
+
warn_no_return = true
|
| 125 |
+
warn_unreachable = true
|
| 126 |
+
strict_equality = true
|
| 127 |
+
|
| 128 |
+
[tool.pytest.ini_options]
|
| 129 |
+
minversion = "6.0"
|
| 130 |
+
addopts = "-ra -q --strict-markers --strict-config"
|
| 131 |
+
testpaths = ["tests"]
|
| 132 |
+
python_files = ["test_*.py", "*_test.py"]
|
| 133 |
+
python_classes = ["Test*"]
|
| 134 |
+
python_functions = ["test_*"]
|
| 135 |
+
markers = [
|
| 136 |
+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
| 137 |
+
"integration: marks tests as integration tests",
|
| 138 |
+
"unit: marks tests as unit tests",
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
[tool.coverage.run]
|
| 142 |
+
source = ["ttsfm"]
|
| 143 |
+
omit = [
|
| 144 |
+
"*/tests/*",
|
| 145 |
+
"*/test_*",
|
| 146 |
+
"setup.py",
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
[tool.coverage.report]
|
| 150 |
+
exclude_lines = [
|
| 151 |
+
"pragma: no cover",
|
| 152 |
+
"def __repr__",
|
| 153 |
+
"if self.debug:",
|
| 154 |
+
"if settings.DEBUG",
|
| 155 |
+
"raise AssertionError",
|
| 156 |
+
"raise NotImplementedError",
|
| 157 |
+
"if 0:",
|
| 158 |
+
"if __name__ == .__main__.:",
|
| 159 |
+
"class .*\\bProtocol\\):",
|
| 160 |
+
"@(abc\\.)?abstractmethod",
|
| 161 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies for the TTSFM package
|
| 2 |
+
requests>=2.25.0
|
| 3 |
+
aiohttp>=3.8.0
|
| 4 |
+
fake-useragent>=1.4.0
|
ttsfm-web/app.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TTSFM Web Application
|
| 3 |
+
|
| 4 |
+
A Flask web application that provides a user-friendly interface
|
| 5 |
+
for the TTSFM text-to-speech package.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Dict, Any, Optional
|
| 14 |
+
|
| 15 |
+
from flask import Flask, request, jsonify, send_file, Response, render_template
|
| 16 |
+
from flask_cors import CORS
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
|
| 19 |
+
# Import the TTSFM package
|
| 20 |
+
try:
|
| 21 |
+
from ttsfm import TTSClient, Voice, AudioFormat, TTSException
|
| 22 |
+
from ttsfm.exceptions import APIException, NetworkException, ValidationException
|
| 23 |
+
from ttsfm.utils import validate_text_length, split_text_by_length
|
| 24 |
+
except ImportError:
|
| 25 |
+
# Fallback for development when package is not installed
|
| 26 |
+
import sys
|
| 27 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 28 |
+
from ttsfm import TTSClient, Voice, AudioFormat, TTSException
|
| 29 |
+
from ttsfm.exceptions import APIException, NetworkException, ValidationException
|
| 30 |
+
from ttsfm.utils import validate_text_length, split_text_by_length
|
| 31 |
+
|
| 32 |
+
# Load environment variables
|
| 33 |
+
load_dotenv()
|
| 34 |
+
|
| 35 |
+
# Configure logging
|
| 36 |
+
logging.basicConfig(
|
| 37 |
+
level=logging.INFO,
|
| 38 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 39 |
+
)
|
| 40 |
+
logger = logging.getLogger(__name__)
|
| 41 |
+
|
| 42 |
+
# Create Flask app
|
| 43 |
+
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
| 44 |
+
CORS(app)
|
| 45 |
+
|
| 46 |
+
# Configuration
|
| 47 |
+
HOST = os.getenv("HOST", "localhost")
|
| 48 |
+
PORT = int(os.getenv("PORT", "8000"))
|
| 49 |
+
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
| 50 |
+
|
| 51 |
+
# Create TTS client - now uses openai.fm directly, no configuration needed
|
| 52 |
+
tts_client = TTSClient()
|
| 53 |
+
|
| 54 |
+
logger.info("Initialized web app with TTSFM using openai.fm free service")
|
| 55 |
+
|
| 56 |
+
@app.route('/')
|
| 57 |
+
def index():
|
| 58 |
+
"""Serve the main web interface."""
|
| 59 |
+
return render_template('index.html')
|
| 60 |
+
|
| 61 |
+
@app.route('/playground')
|
| 62 |
+
def playground():
|
| 63 |
+
"""Serve the interactive playground."""
|
| 64 |
+
return render_template('playground.html')
|
| 65 |
+
|
| 66 |
+
@app.route('/docs')
|
| 67 |
+
def docs():
|
| 68 |
+
"""Serve the API documentation."""
|
| 69 |
+
return render_template('docs.html')
|
| 70 |
+
|
| 71 |
+
@app.route('/api/voices', methods=['GET'])
|
| 72 |
+
def get_voices():
|
| 73 |
+
"""Get list of available voices."""
|
| 74 |
+
try:
|
| 75 |
+
voices = [
|
| 76 |
+
{
|
| 77 |
+
"id": voice.value,
|
| 78 |
+
"name": voice.value.title(),
|
| 79 |
+
"description": f"{voice.value.title()} voice"
|
| 80 |
+
}
|
| 81 |
+
for voice in Voice
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
return jsonify({
|
| 85 |
+
"voices": voices,
|
| 86 |
+
"count": len(voices)
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error(f"Error getting voices: {e}")
|
| 91 |
+
return jsonify({"error": "Failed to get voices"}), 500
|
| 92 |
+
|
| 93 |
+
@app.route('/api/formats', methods=['GET'])
|
| 94 |
+
def get_formats():
|
| 95 |
+
"""Get list of supported audio formats."""
|
| 96 |
+
try:
|
| 97 |
+
formats = [
|
| 98 |
+
{
|
| 99 |
+
"id": "mp3",
|
| 100 |
+
"name": "MP3",
|
| 101 |
+
"mime_type": "audio/mpeg",
|
| 102 |
+
"description": "MP3 audio format - good quality, small file size",
|
| 103 |
+
"quality": "Good",
|
| 104 |
+
"file_size": "Small",
|
| 105 |
+
"use_case": "Web, mobile apps, general use"
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"id": "opus",
|
| 109 |
+
"name": "OPUS",
|
| 110 |
+
"mime_type": "audio/opus",
|
| 111 |
+
"description": "OPUS audio format - excellent quality, small file size",
|
| 112 |
+
"quality": "Excellent",
|
| 113 |
+
"file_size": "Small",
|
| 114 |
+
"use_case": "Web streaming, VoIP"
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"id": "aac",
|
| 118 |
+
"name": "AAC",
|
| 119 |
+
"mime_type": "audio/aac",
|
| 120 |
+
"description": "AAC audio format - good quality, medium file size",
|
| 121 |
+
"quality": "Good",
|
| 122 |
+
"file_size": "Medium",
|
| 123 |
+
"use_case": "Apple devices, streaming"
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"id": "flac",
|
| 127 |
+
"name": "FLAC",
|
| 128 |
+
"mime_type": "audio/flac",
|
| 129 |
+
"description": "FLAC audio format - lossless quality, large file size",
|
| 130 |
+
"quality": "Lossless",
|
| 131 |
+
"file_size": "Large",
|
| 132 |
+
"use_case": "High-quality archival"
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"id": "wav",
|
| 136 |
+
"name": "WAV",
|
| 137 |
+
"mime_type": "audio/wav",
|
| 138 |
+
"description": "WAV audio format - lossless quality, large file size",
|
| 139 |
+
"quality": "Lossless",
|
| 140 |
+
"file_size": "Large",
|
| 141 |
+
"use_case": "Professional audio"
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"id": "pcm",
|
| 145 |
+
"name": "PCM",
|
| 146 |
+
"mime_type": "audio/pcm",
|
| 147 |
+
"description": "PCM audio format - raw audio data, large file size",
|
| 148 |
+
"quality": "Raw",
|
| 149 |
+
"file_size": "Large",
|
| 150 |
+
"use_case": "Audio processing"
|
| 151 |
+
}
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
return jsonify({
|
| 155 |
+
"formats": formats,
|
| 156 |
+
"count": len(formats)
|
| 157 |
+
})
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
logger.error(f"Error getting formats: {e}")
|
| 161 |
+
return jsonify({"error": "Failed to get formats"}), 500
|
| 162 |
+
|
| 163 |
+
@app.route('/api/validate-text', methods=['POST'])
|
| 164 |
+
def validate_text():
|
| 165 |
+
"""Validate text length and provide splitting suggestions."""
|
| 166 |
+
try:
|
| 167 |
+
data = request.get_json()
|
| 168 |
+
if not data:
|
| 169 |
+
return jsonify({"error": "No JSON data provided"}), 400
|
| 170 |
+
|
| 171 |
+
text = data.get('text', '').strip()
|
| 172 |
+
max_length = data.get('max_length', 4096)
|
| 173 |
+
|
| 174 |
+
if not text:
|
| 175 |
+
return jsonify({"error": "Text is required"}), 400
|
| 176 |
+
|
| 177 |
+
text_length = len(text)
|
| 178 |
+
is_valid = text_length <= max_length
|
| 179 |
+
|
| 180 |
+
result = {
|
| 181 |
+
"text_length": text_length,
|
| 182 |
+
"max_length": max_length,
|
| 183 |
+
"is_valid": is_valid,
|
| 184 |
+
"needs_splitting": not is_valid
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
if not is_valid:
|
| 188 |
+
# Provide splitting suggestions
|
| 189 |
+
chunks = split_text_by_length(text, max_length, preserve_words=True)
|
| 190 |
+
result.update({
|
| 191 |
+
"suggested_chunks": len(chunks),
|
| 192 |
+
"chunk_preview": [chunk[:100] + "..." if len(chunk) > 100 else chunk for chunk in chunks[:3]]
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
return jsonify(result)
|
| 196 |
+
|
| 197 |
+
except Exception as e:
|
| 198 |
+
logger.error(f"Text validation error: {e}")
|
| 199 |
+
return jsonify({"error": "Text validation failed"}), 500
|
| 200 |
+
|
| 201 |
+
@app.route('/api/generate', methods=['POST'])
|
| 202 |
+
def generate_speech():
|
| 203 |
+
"""Generate speech from text using the TTSFM package."""
|
| 204 |
+
try:
|
| 205 |
+
# Parse request data
|
| 206 |
+
data = request.get_json()
|
| 207 |
+
if not data:
|
| 208 |
+
return jsonify({"error": "No JSON data provided"}), 400
|
| 209 |
+
|
| 210 |
+
# Extract parameters
|
| 211 |
+
text = data.get('text', '').strip()
|
| 212 |
+
voice = data.get('voice', Voice.ALLOY.value)
|
| 213 |
+
response_format = data.get('format', AudioFormat.MP3.value)
|
| 214 |
+
instructions = data.get('instructions', '').strip() or None
|
| 215 |
+
max_length = data.get('max_length', 4096)
|
| 216 |
+
validate_length = data.get('validate_length', True)
|
| 217 |
+
|
| 218 |
+
# Validate required fields
|
| 219 |
+
if not text:
|
| 220 |
+
return jsonify({"error": "Text is required"}), 400
|
| 221 |
+
|
| 222 |
+
# Validate voice
|
| 223 |
+
try:
|
| 224 |
+
voice_enum = Voice(voice.lower())
|
| 225 |
+
except ValueError:
|
| 226 |
+
return jsonify({
|
| 227 |
+
"error": f"Invalid voice: {voice}. Must be one of: {[v.value for v in Voice]}"
|
| 228 |
+
}), 400
|
| 229 |
+
|
| 230 |
+
# Validate format
|
| 231 |
+
try:
|
| 232 |
+
format_enum = AudioFormat(response_format.lower())
|
| 233 |
+
except ValueError:
|
| 234 |
+
return jsonify({
|
| 235 |
+
"error": f"Invalid format: {response_format}. Must be one of: {[f.value for f in AudioFormat]}"
|
| 236 |
+
}), 400
|
| 237 |
+
|
| 238 |
+
logger.info(f"Generating speech: text='{text[:50]}...', voice={voice}, format={response_format}")
|
| 239 |
+
|
| 240 |
+
# Generate speech using the TTSFM package with validation
|
| 241 |
+
response = tts_client.generate_speech(
|
| 242 |
+
text=text,
|
| 243 |
+
voice=voice_enum,
|
| 244 |
+
response_format=format_enum,
|
| 245 |
+
instructions=instructions,
|
| 246 |
+
max_length=max_length,
|
| 247 |
+
validate_length=validate_length
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Return audio data
|
| 251 |
+
return Response(
|
| 252 |
+
response.audio_data,
|
| 253 |
+
mimetype=response.content_type,
|
| 254 |
+
headers={
|
| 255 |
+
'Content-Disposition': f'attachment; filename="speech.{response.format.value}"',
|
| 256 |
+
'Content-Length': str(response.size),
|
| 257 |
+
'X-Audio-Format': response.format.value,
|
| 258 |
+
'X-Audio-Size': str(response.size)
|
| 259 |
+
}
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
except ValidationException as e:
|
| 263 |
+
logger.warning(f"Validation error: {e}")
|
| 264 |
+
return jsonify({"error": str(e)}), 400
|
| 265 |
+
|
| 266 |
+
except APIException as e:
|
| 267 |
+
logger.error(f"API error: {e}")
|
| 268 |
+
return jsonify({
|
| 269 |
+
"error": str(e),
|
| 270 |
+
"status_code": getattr(e, 'status_code', 500)
|
| 271 |
+
}), getattr(e, 'status_code', 500)
|
| 272 |
+
|
| 273 |
+
except NetworkException as e:
|
| 274 |
+
logger.error(f"Network error: {e}")
|
| 275 |
+
return jsonify({
|
| 276 |
+
"error": "TTS service is currently unavailable",
|
| 277 |
+
"details": str(e)
|
| 278 |
+
}), 503
|
| 279 |
+
|
| 280 |
+
except TTSException as e:
|
| 281 |
+
logger.error(f"TTS error: {e}")
|
| 282 |
+
return jsonify({"error": str(e)}), 500
|
| 283 |
+
|
| 284 |
+
except Exception as e:
|
| 285 |
+
logger.error(f"Unexpected error: {e}")
|
| 286 |
+
return jsonify({"error": "Internal server error"}), 500
|
| 287 |
+
|
| 288 |
+
@app.route('/api/generate-batch', methods=['POST'])
|
| 289 |
+
def generate_speech_batch():
|
| 290 |
+
"""Generate speech from long text by splitting into chunks."""
|
| 291 |
+
try:
|
| 292 |
+
data = request.get_json()
|
| 293 |
+
if not data:
|
| 294 |
+
return jsonify({"error": "No JSON data provided"}), 400
|
| 295 |
+
|
| 296 |
+
text = data.get('text', '').strip()
|
| 297 |
+
voice = data.get('voice', Voice.ALLOY.value)
|
| 298 |
+
response_format = data.get('format', AudioFormat.MP3.value)
|
| 299 |
+
instructions = data.get('instructions', '').strip() or None
|
| 300 |
+
max_length = data.get('max_length', 4096)
|
| 301 |
+
preserve_words = data.get('preserve_words', True)
|
| 302 |
+
|
| 303 |
+
if not text:
|
| 304 |
+
return jsonify({"error": "Text is required"}), 400
|
| 305 |
+
|
| 306 |
+
# Validate voice and format
|
| 307 |
+
try:
|
| 308 |
+
voice_enum = Voice(voice.lower())
|
| 309 |
+
format_enum = AudioFormat(response_format.lower())
|
| 310 |
+
except ValueError as e:
|
| 311 |
+
return jsonify({"error": f"Invalid voice or format: {e}"}), 400
|
| 312 |
+
|
| 313 |
+
# Split text into chunks
|
| 314 |
+
chunks = split_text_by_length(text, max_length, preserve_words)
|
| 315 |
+
|
| 316 |
+
if not chunks:
|
| 317 |
+
return jsonify({"error": "No valid text chunks found"}), 400
|
| 318 |
+
|
| 319 |
+
logger.info(f"Processing {len(chunks)} chunks for batch generation")
|
| 320 |
+
|
| 321 |
+
# Generate speech for each chunk
|
| 322 |
+
results = []
|
| 323 |
+
for i, chunk in enumerate(chunks):
|
| 324 |
+
try:
|
| 325 |
+
response = tts_client.generate_speech(
|
| 326 |
+
text=chunk,
|
| 327 |
+
voice=voice_enum,
|
| 328 |
+
response_format=format_enum,
|
| 329 |
+
instructions=instructions,
|
| 330 |
+
max_length=max_length,
|
| 331 |
+
validate_length=False # Already split
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
# Convert to base64 for JSON response
|
| 335 |
+
import base64
|
| 336 |
+
audio_b64 = base64.b64encode(response.audio_data).decode('utf-8')
|
| 337 |
+
|
| 338 |
+
results.append({
|
| 339 |
+
"chunk_index": i + 1,
|
| 340 |
+
"chunk_text": chunk[:100] + "..." if len(chunk) > 100 else chunk,
|
| 341 |
+
"audio_data": audio_b64,
|
| 342 |
+
"content_type": response.content_type,
|
| 343 |
+
"size": response.size,
|
| 344 |
+
"format": response.format.value
|
| 345 |
+
})
|
| 346 |
+
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.error(f"Failed to generate chunk {i+1}: {e}")
|
| 349 |
+
results.append({
|
| 350 |
+
"chunk_index": i + 1,
|
| 351 |
+
"chunk_text": chunk[:100] + "..." if len(chunk) > 100 else chunk,
|
| 352 |
+
"error": str(e)
|
| 353 |
+
})
|
| 354 |
+
|
| 355 |
+
return jsonify({
|
| 356 |
+
"total_chunks": len(chunks),
|
| 357 |
+
"successful_chunks": len([r for r in results if "audio_data" in r]),
|
| 358 |
+
"results": results
|
| 359 |
+
})
|
| 360 |
+
|
| 361 |
+
except Exception as e:
|
| 362 |
+
logger.error(f"Batch generation error: {e}")
|
| 363 |
+
return jsonify({"error": "Batch generation failed"}), 500
|
| 364 |
+
|
| 365 |
+
@app.route('/api/status', methods=['GET'])
|
| 366 |
+
def get_status():
|
| 367 |
+
"""Get service status."""
|
| 368 |
+
try:
|
| 369 |
+
# Try to make a simple request to check if the TTS service is available
|
| 370 |
+
test_response = tts_client.generate_speech(
|
| 371 |
+
text="test",
|
| 372 |
+
voice=Voice.ALLOY,
|
| 373 |
+
response_format=AudioFormat.MP3
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
return jsonify({
|
| 377 |
+
"status": "online",
|
| 378 |
+
"tts_service": "openai.fm (free)",
|
| 379 |
+
"package_version": "3.0.0",
|
| 380 |
+
"timestamp": datetime.now().isoformat()
|
| 381 |
+
})
|
| 382 |
+
|
| 383 |
+
except Exception as e:
|
| 384 |
+
logger.error(f"Status check failed: {e}")
|
| 385 |
+
return jsonify({
|
| 386 |
+
"status": "error",
|
| 387 |
+
"tts_service": "openai.fm (free)",
|
| 388 |
+
"error": str(e),
|
| 389 |
+
"timestamp": datetime.now().isoformat()
|
| 390 |
+
}), 503
|
| 391 |
+
|
| 392 |
+
@app.route('/api/health', methods=['GET'])
|
| 393 |
+
def health_check():
|
| 394 |
+
"""Simple health check endpoint."""
|
| 395 |
+
return jsonify({
|
| 396 |
+
"status": "healthy",
|
| 397 |
+
"timestamp": datetime.now().isoformat()
|
| 398 |
+
})
|
| 399 |
+
|
| 400 |
+
# OpenAI-compatible API endpoints
|
| 401 |
+
@app.route('/v1/audio/speech', methods=['POST'])
|
| 402 |
+
def openai_speech():
|
| 403 |
+
"""OpenAI-compatible speech generation endpoint."""
|
| 404 |
+
try:
|
| 405 |
+
# Parse request data
|
| 406 |
+
data = request.get_json()
|
| 407 |
+
if not data:
|
| 408 |
+
return jsonify({
|
| 409 |
+
"error": {
|
| 410 |
+
"message": "No JSON data provided",
|
| 411 |
+
"type": "invalid_request_error",
|
| 412 |
+
"code": "missing_data"
|
| 413 |
+
}
|
| 414 |
+
}), 400
|
| 415 |
+
|
| 416 |
+
# Extract OpenAI-compatible parameters
|
| 417 |
+
model = data.get('model', 'gpt-4o-mini-tts') # Accept but ignore model
|
| 418 |
+
input_text = data.get('input', '').strip()
|
| 419 |
+
voice = data.get('voice', 'alloy')
|
| 420 |
+
response_format = data.get('response_format', 'mp3')
|
| 421 |
+
instructions = data.get('instructions', '').strip() or None
|
| 422 |
+
speed = data.get('speed', 1.0) # Accept but ignore speed
|
| 423 |
+
|
| 424 |
+
# Validate required fields
|
| 425 |
+
if not input_text:
|
| 426 |
+
return jsonify({
|
| 427 |
+
"error": {
|
| 428 |
+
"message": "Input text is required",
|
| 429 |
+
"type": "invalid_request_error",
|
| 430 |
+
"code": "missing_input"
|
| 431 |
+
}
|
| 432 |
+
}), 400
|
| 433 |
+
|
| 434 |
+
# Validate voice
|
| 435 |
+
try:
|
| 436 |
+
voice_enum = Voice(voice.lower())
|
| 437 |
+
except ValueError:
|
| 438 |
+
return jsonify({
|
| 439 |
+
"error": {
|
| 440 |
+
"message": f"Invalid voice: {voice}. Must be one of: {[v.value for v in Voice]}",
|
| 441 |
+
"type": "invalid_request_error",
|
| 442 |
+
"code": "invalid_voice"
|
| 443 |
+
}
|
| 444 |
+
}), 400
|
| 445 |
+
|
| 446 |
+
# Validate format
|
| 447 |
+
try:
|
| 448 |
+
format_enum = AudioFormat(response_format.lower())
|
| 449 |
+
except ValueError:
|
| 450 |
+
return jsonify({
|
| 451 |
+
"error": {
|
| 452 |
+
"message": f"Invalid response_format: {response_format}. Must be one of: {[f.value for f in AudioFormat]}",
|
| 453 |
+
"type": "invalid_request_error",
|
| 454 |
+
"code": "invalid_format"
|
| 455 |
+
}
|
| 456 |
+
}), 400
|
| 457 |
+
|
| 458 |
+
logger.info(f"OpenAI API: Generating speech: text='{input_text[:50]}...', voice={voice}, format={response_format}")
|
| 459 |
+
|
| 460 |
+
# Generate speech using the TTSFM package
|
| 461 |
+
response = tts_client.generate_speech(
|
| 462 |
+
text=input_text,
|
| 463 |
+
voice=voice_enum,
|
| 464 |
+
response_format=format_enum,
|
| 465 |
+
instructions=instructions,
|
| 466 |
+
max_length=4096,
|
| 467 |
+
validate_length=True
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
# Return audio data in OpenAI format
|
| 471 |
+
return Response(
|
| 472 |
+
response.audio_data,
|
| 473 |
+
mimetype=response.content_type,
|
| 474 |
+
headers={
|
| 475 |
+
'Content-Type': response.content_type,
|
| 476 |
+
'Content-Length': str(response.size),
|
| 477 |
+
'X-Audio-Format': response.format.value,
|
| 478 |
+
'X-Audio-Size': str(response.size),
|
| 479 |
+
'X-Powered-By': 'TTSFM-OpenAI-Compatible'
|
| 480 |
+
}
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
except ValidationException as e:
|
| 484 |
+
logger.warning(f"OpenAI API validation error: {e}")
|
| 485 |
+
return jsonify({
|
| 486 |
+
"error": {
|
| 487 |
+
"message": str(e),
|
| 488 |
+
"type": "invalid_request_error",
|
| 489 |
+
"code": "validation_error"
|
| 490 |
+
}
|
| 491 |
+
}), 400
|
| 492 |
+
|
| 493 |
+
except APIException as e:
|
| 494 |
+
logger.error(f"OpenAI API error: {e}")
|
| 495 |
+
return jsonify({
|
| 496 |
+
"error": {
|
| 497 |
+
"message": str(e),
|
| 498 |
+
"type": "api_error",
|
| 499 |
+
"code": "tts_error"
|
| 500 |
+
}
|
| 501 |
+
}), getattr(e, 'status_code', 500)
|
| 502 |
+
|
| 503 |
+
except NetworkException as e:
|
| 504 |
+
logger.error(f"OpenAI API network error: {e}")
|
| 505 |
+
return jsonify({
|
| 506 |
+
"error": {
|
| 507 |
+
"message": "TTS service is currently unavailable",
|
| 508 |
+
"type": "service_unavailable_error",
|
| 509 |
+
"code": "service_unavailable"
|
| 510 |
+
}
|
| 511 |
+
}), 503
|
| 512 |
+
|
| 513 |
+
except Exception as e:
|
| 514 |
+
logger.error(f"OpenAI API unexpected error: {e}")
|
| 515 |
+
return jsonify({
|
| 516 |
+
"error": {
|
| 517 |
+
"message": "An unexpected error occurred",
|
| 518 |
+
"type": "internal_error",
|
| 519 |
+
"code": "internal_error"
|
| 520 |
+
}
|
| 521 |
+
}), 500
|
| 522 |
+
|
| 523 |
+
@app.route('/v1/models', methods=['GET'])
|
| 524 |
+
def openai_models():
|
| 525 |
+
"""OpenAI-compatible models endpoint."""
|
| 526 |
+
return jsonify({
|
| 527 |
+
"object": "list",
|
| 528 |
+
"data": [
|
| 529 |
+
{
|
| 530 |
+
"id": "gpt-4o-mini-tts",
|
| 531 |
+
"object": "model",
|
| 532 |
+
"created": 1699564800,
|
| 533 |
+
"owned_by": "ttsfm",
|
| 534 |
+
"permission": [],
|
| 535 |
+
"root": "gpt-4o-mini-tts",
|
| 536 |
+
"parent": None
|
| 537 |
+
}
|
| 538 |
+
]
|
| 539 |
+
})
|
| 540 |
+
|
| 541 |
+
@app.errorhandler(404)
|
| 542 |
+
def not_found(error):
|
| 543 |
+
"""Handle 404 errors."""
|
| 544 |
+
return jsonify({"error": "Endpoint not found"}), 404
|
| 545 |
+
|
| 546 |
+
@app.errorhandler(405)
|
| 547 |
+
def method_not_allowed(error):
|
| 548 |
+
"""Handle 405 errors."""
|
| 549 |
+
return jsonify({"error": "Method not allowed"}), 405
|
| 550 |
+
|
| 551 |
+
@app.errorhandler(500)
|
| 552 |
+
def internal_error(error):
|
| 553 |
+
"""Handle 500 errors."""
|
| 554 |
+
logger.error(f"Internal server error: {error}")
|
| 555 |
+
return jsonify({"error": "Internal server error"}), 500
|
| 556 |
+
|
| 557 |
+
if __name__ == '__main__':
|
| 558 |
+
logger.info(f"Starting TTSFM web application on {HOST}:{PORT}")
|
| 559 |
+
logger.info("Using openai.fm free TTS service")
|
| 560 |
+
logger.info(f"Debug mode: {DEBUG}")
|
| 561 |
+
|
| 562 |
+
try:
|
| 563 |
+
app.run(
|
| 564 |
+
host=HOST,
|
| 565 |
+
port=PORT,
|
| 566 |
+
debug=DEBUG
|
| 567 |
+
)
|
| 568 |
+
except KeyboardInterrupt:
|
| 569 |
+
logger.info("Application stopped by user")
|
| 570 |
+
except Exception as e:
|
| 571 |
+
logger.error(f"Failed to start application: {e}")
|
| 572 |
+
finally:
|
| 573 |
+
# Clean up TTS client
|
| 574 |
+
tts_client.close()
|
ttsfm-web/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Web application dependencies
|
| 2 |
+
flask>=2.0.0
|
| 3 |
+
flask-cors>=3.0.10
|
| 4 |
+
waitress>=3.0.0
|
| 5 |
+
python-dotenv>=1.0.0
|
| 6 |
+
|
| 7 |
+
# TTSFM package (install from local directory or PyPI)
|
| 8 |
+
# For local development: pip install -e ../
|
| 9 |
+
# For Docker/production: installed via pyproject.toml[web] dependencies
|
ttsfm-web/static/css/style.css
ADDED
|
@@ -0,0 +1,1390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* TTSFM Web Application Custom Styles */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
/* Clean Color Palette */
|
| 5 |
+
--primary-color: #2563eb;
|
| 6 |
+
--primary-dark: #1d4ed8;
|
| 7 |
+
--primary-light: #3b82f6;
|
| 8 |
+
--secondary-color: #64748b;
|
| 9 |
+
--secondary-dark: #475569;
|
| 10 |
+
--accent-color: #10b981;
|
| 11 |
+
--accent-dark: #059669;
|
| 12 |
+
|
| 13 |
+
/* Status Colors */
|
| 14 |
+
--success-color: #10b981;
|
| 15 |
+
--warning-color: #f59e0b;
|
| 16 |
+
--danger-color: #ef4444;
|
| 17 |
+
--info-color: #3b82f6;
|
| 18 |
+
|
| 19 |
+
/* Clean Neutral Colors */
|
| 20 |
+
--light-color: #ffffff;
|
| 21 |
+
--light-gray: #f8fafc;
|
| 22 |
+
--medium-gray: #64748b;
|
| 23 |
+
--dark-color: #1e293b;
|
| 24 |
+
--text-color: #374151;
|
| 25 |
+
--text-muted: #6b7280;
|
| 26 |
+
|
| 27 |
+
/* Design System */
|
| 28 |
+
--border-radius: 0.75rem;
|
| 29 |
+
--border-radius-sm: 0.5rem;
|
| 30 |
+
--border-radius-lg: 1rem;
|
| 31 |
+
--box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
| 32 |
+
--box-shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
| 33 |
+
--box-shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
| 34 |
+
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 35 |
+
--transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
| 36 |
+
|
| 37 |
+
/* Gradients */
|
| 38 |
+
--gradient-primary: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
| 39 |
+
--gradient-secondary: linear-gradient(135deg, var(--secondary-color) 0%, var(--secondary-dark) 100%);
|
| 40 |
+
--gradient-accent: linear-gradient(135deg, var(--accent-color) 0%, var(--accent-dark) 100%);
|
| 41 |
+
--gradient-hero: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* Global Styles */
|
| 45 |
+
body {
|
| 46 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 47 |
+
line-height: 1.6;
|
| 48 |
+
color: var(--text-color);
|
| 49 |
+
background-color: #ffffff;
|
| 50 |
+
font-weight: 400;
|
| 51 |
+
-webkit-font-smoothing: antialiased;
|
| 52 |
+
-moz-osx-font-smoothing: grayscale;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/* Enhanced Typography */
|
| 56 |
+
h1, h2, h3, h4, h5, h6 {
|
| 57 |
+
font-weight: 700;
|
| 58 |
+
line-height: 1.3;
|
| 59 |
+
color: var(--dark-color);
|
| 60 |
+
letter-spacing: -0.025em;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.display-1, .display-2, .display-3, .display-4 {
|
| 64 |
+
font-weight: 800;
|
| 65 |
+
letter-spacing: -0.05em;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.lead {
|
| 69 |
+
font-size: 1.125rem;
|
| 70 |
+
font-weight: 400;
|
| 71 |
+
color: var(--text-muted);
|
| 72 |
+
line-height: 1.8;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* Simplified Button Styles */
|
| 76 |
+
.btn {
|
| 77 |
+
font-weight: 600;
|
| 78 |
+
border-radius: var(--border-radius-sm);
|
| 79 |
+
transition: all 0.2s ease;
|
| 80 |
+
letter-spacing: 0.025em;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.btn-primary {
|
| 84 |
+
background-color: var(--primary-color);
|
| 85 |
+
border-color: var(--primary-color);
|
| 86 |
+
color: white;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.btn-primary:hover {
|
| 90 |
+
background-color: var(--primary-dark);
|
| 91 |
+
border-color: var(--primary-dark);
|
| 92 |
+
color: white;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.btn-outline-primary {
|
| 96 |
+
border: 2px solid var(--primary-color);
|
| 97 |
+
color: var(--primary-color);
|
| 98 |
+
background: transparent;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.btn-outline-primary:hover {
|
| 102 |
+
background: var(--primary-color);
|
| 103 |
+
border-color: var(--primary-color);
|
| 104 |
+
color: white;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.btn-lg {
|
| 108 |
+
padding: 0.875rem 2rem;
|
| 109 |
+
font-size: 1.125rem;
|
| 110 |
+
border-radius: var(--border-radius);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.btn-sm {
|
| 114 |
+
padding: 0.5rem 1rem;
|
| 115 |
+
font-size: 0.875rem;
|
| 116 |
+
border-radius: var(--border-radius-sm);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* Clean Card Styles */
|
| 120 |
+
.card {
|
| 121 |
+
border: 1px solid #e5e7eb;
|
| 122 |
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
| 123 |
+
transition: all 0.2s ease;
|
| 124 |
+
border-radius: 12px;
|
| 125 |
+
background: white;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.card:hover {
|
| 129 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
|
| 130 |
+
border-color: #d1d5db;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.card-body {
|
| 134 |
+
padding: 2rem;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/* Clean Hero Section */
|
| 138 |
+
.hero-section {
|
| 139 |
+
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
|
| 140 |
+
color: var(--text-color);
|
| 141 |
+
padding: 6rem 0;
|
| 142 |
+
min-height: 80vh;
|
| 143 |
+
display: flex;
|
| 144 |
+
align-items: center;
|
| 145 |
+
border-bottom: 1px solid #e5e7eb;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.min-vh-75 {
|
| 149 |
+
min-height: 75vh;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/* Status Indicators */
|
| 153 |
+
.status-indicator {
|
| 154 |
+
display: inline-block;
|
| 155 |
+
width: 8px;
|
| 156 |
+
height: 8px;
|
| 157 |
+
border-radius: 50%;
|
| 158 |
+
background-color: #6c757d;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.status-online {
|
| 162 |
+
background-color: #28a745;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.status-offline {
|
| 166 |
+
background-color: #dc3545;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/* Footer */
|
| 170 |
+
.footer {
|
| 171 |
+
margin-top: auto;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/* Clean Code Blocks */
|
| 175 |
+
pre {
|
| 176 |
+
background-color: #f8fafc !important;
|
| 177 |
+
border: 1px solid #e5e7eb;
|
| 178 |
+
border-radius: 8px;
|
| 179 |
+
font-size: 0.875rem;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
code {
|
| 183 |
+
color: #374151;
|
| 184 |
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/* Enhanced Form Styles */
|
| 188 |
+
.form-control, .form-select {
|
| 189 |
+
border-radius: var(--border-radius-sm);
|
| 190 |
+
border: 2px solid #e2e8f0;
|
| 191 |
+
transition: var(--transition);
|
| 192 |
+
padding: 0.875rem 1rem;
|
| 193 |
+
font-size: 1rem;
|
| 194 |
+
background-color: #ffffff;
|
| 195 |
+
color: var(--text-color);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.form-control:focus, .form-select:focus {
|
| 199 |
+
border-color: var(--primary-color);
|
| 200 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 201 |
+
outline: none;
|
| 202 |
+
background-color: #ffffff;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.form-control:hover, .form-select:hover {
|
| 206 |
+
border-color: #cbd5e1;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.form-label {
|
| 210 |
+
font-weight: 600;
|
| 211 |
+
color: var(--dark-color);
|
| 212 |
+
margin-bottom: 0.75rem;
|
| 213 |
+
font-size: 0.95rem;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.form-text {
|
| 217 |
+
color: var(--text-muted);
|
| 218 |
+
font-size: 0.875rem;
|
| 219 |
+
margin-top: 0.5rem;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.form-check-input {
|
| 223 |
+
border-radius: var(--border-radius-sm);
|
| 224 |
+
border: 2px solid #e2e8f0;
|
| 225 |
+
width: 1.25rem;
|
| 226 |
+
height: 1.25rem;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.form-check-input:checked {
|
| 230 |
+
background-color: var(--primary-color);
|
| 231 |
+
border-color: var(--primary-color);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.form-check-input:focus {
|
| 235 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.form-check-label {
|
| 239 |
+
color: var(--text-color);
|
| 240 |
+
font-weight: 500;
|
| 241 |
+
margin-left: 0.5rem;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
/* Enhanced Status Indicators */
|
| 245 |
+
.status-indicator {
|
| 246 |
+
display: inline-block;
|
| 247 |
+
width: 12px;
|
| 248 |
+
height: 12px;
|
| 249 |
+
border-radius: 50%;
|
| 250 |
+
margin-right: 8px;
|
| 251 |
+
position: relative;
|
| 252 |
+
animation: statusPulse 2s infinite;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.status-indicator::before {
|
| 256 |
+
content: '';
|
| 257 |
+
position: absolute;
|
| 258 |
+
top: -2px;
|
| 259 |
+
left: -2px;
|
| 260 |
+
right: -2px;
|
| 261 |
+
bottom: -2px;
|
| 262 |
+
border-radius: 50%;
|
| 263 |
+
opacity: 0.3;
|
| 264 |
+
animation: statusRing 2s infinite;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.status-online {
|
| 268 |
+
background-color: var(--success-color);
|
| 269 |
+
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.status-online::before {
|
| 273 |
+
background-color: var(--success-color);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.status-offline {
|
| 277 |
+
background-color: var(--danger-color);
|
| 278 |
+
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.status-offline::before {
|
| 282 |
+
background-color: var(--danger-color);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
@keyframes statusPulse {
|
| 286 |
+
0%, 100% { opacity: 1; }
|
| 287 |
+
50% { opacity: 0.7; }
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
@keyframes statusRing {
|
| 291 |
+
0% { transform: scale(0.8); opacity: 0.8; }
|
| 292 |
+
100% { transform: scale(1.4); opacity: 0; }
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/* Enhanced Audio Player */
|
| 296 |
+
.audio-player {
|
| 297 |
+
width: 100%;
|
| 298 |
+
margin-top: 1rem;
|
| 299 |
+
border-radius: var(--border-radius);
|
| 300 |
+
box-shadow: var(--box-shadow);
|
| 301 |
+
background: var(--light-color);
|
| 302 |
+
padding: 0.5rem;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.audio-player::-webkit-media-controls-panel {
|
| 306 |
+
background-color: var(--light-color);
|
| 307 |
+
border-radius: var(--border-radius-sm);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
/* Enhanced Sections */
|
| 311 |
+
.features-section {
|
| 312 |
+
padding: 6rem 0;
|
| 313 |
+
background: linear-gradient(180deg, #ffffff 0%, var(--light-color) 100%);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.stats-section {
|
| 317 |
+
padding: 4rem 0;
|
| 318 |
+
background: var(--gradient-primary);
|
| 319 |
+
color: white;
|
| 320 |
+
position: relative;
|
| 321 |
+
overflow: hidden;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.stats-section::before {
|
| 325 |
+
content: '';
|
| 326 |
+
position: absolute;
|
| 327 |
+
top: 0;
|
| 328 |
+
left: 0;
|
| 329 |
+
right: 0;
|
| 330 |
+
bottom: 0;
|
| 331 |
+
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="stats-pattern" width="40" height="40" patternUnits="userSpaceOnUse"><circle cx="20" cy="20" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23stats-pattern)"/></svg>');
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.stat-card {
|
| 335 |
+
text-align: center;
|
| 336 |
+
padding: 2rem 1rem;
|
| 337 |
+
background: rgba(255, 255, 255, 0.1);
|
| 338 |
+
border-radius: var(--border-radius);
|
| 339 |
+
backdrop-filter: blur(10px);
|
| 340 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 341 |
+
transition: var(--transition);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.stat-card:hover {
|
| 345 |
+
transform: translateY(-5px);
|
| 346 |
+
background: rgba(255, 255, 255, 0.15);
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.stat-icon {
|
| 350 |
+
font-size: 2.5rem;
|
| 351 |
+
margin-bottom: 1rem;
|
| 352 |
+
color: rgba(255, 255, 255, 0.9);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.stat-number {
|
| 356 |
+
font-size: 3rem;
|
| 357 |
+
font-weight: 800;
|
| 358 |
+
color: white;
|
| 359 |
+
margin-bottom: 0.5rem;
|
| 360 |
+
display: block;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.stat-label {
|
| 364 |
+
color: rgba(255, 255, 255, 0.9);
|
| 365 |
+
font-weight: 500;
|
| 366 |
+
font-size: 0.95rem;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.quick-start-section {
|
| 370 |
+
padding: 6rem 0;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.use-cases-section {
|
| 374 |
+
padding: 6rem 0;
|
| 375 |
+
background: var(--light-color);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.tech-specs-section {
|
| 379 |
+
padding: 6rem 0;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.faq-section {
|
| 383 |
+
padding: 6rem 0;
|
| 384 |
+
background: var(--light-color);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.final-cta-section {
|
| 388 |
+
padding: 6rem 0;
|
| 389 |
+
background: var(--gradient-hero);
|
| 390 |
+
color: white;
|
| 391 |
+
position: relative;
|
| 392 |
+
overflow: hidden;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.cta-background-animation {
|
| 396 |
+
position: absolute;
|
| 397 |
+
top: 0;
|
| 398 |
+
left: 0;
|
| 399 |
+
right: 0;
|
| 400 |
+
bottom: 0;
|
| 401 |
+
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.05) 50%, transparent 70%);
|
| 402 |
+
animation: shimmer 4s ease-in-out infinite;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
.section-badge {
|
| 406 |
+
display: inline-block;
|
| 407 |
+
background: var(--gradient-primary);
|
| 408 |
+
color: white;
|
| 409 |
+
padding: 0.5rem 1.5rem;
|
| 410 |
+
border-radius: 2rem;
|
| 411 |
+
font-size: 0.875rem;
|
| 412 |
+
font-weight: 600;
|
| 413 |
+
margin-bottom: 1.5rem;
|
| 414 |
+
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/* Enhanced Loading States */
|
| 418 |
+
.loading-spinner {
|
| 419 |
+
display: none;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.loading .loading-spinner {
|
| 423 |
+
display: inline-block;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.loading .btn-text {
|
| 427 |
+
display: none;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.loading {
|
| 431 |
+
position: relative;
|
| 432 |
+
overflow: hidden;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.loading::after {
|
| 436 |
+
content: '';
|
| 437 |
+
position: absolute;
|
| 438 |
+
top: 0;
|
| 439 |
+
left: -100%;
|
| 440 |
+
width: 100%;
|
| 441 |
+
height: 100%;
|
| 442 |
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
| 443 |
+
animation: loading-shimmer 1.5s infinite;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
@keyframes loading-shimmer {
|
| 447 |
+
0% { left: -100%; }
|
| 448 |
+
100% { left: 100%; }
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
/* Enhanced Code Blocks */
|
| 452 |
+
.code-card {
|
| 453 |
+
background: white;
|
| 454 |
+
border-radius: var(--border-radius);
|
| 455 |
+
box-shadow: var(--box-shadow);
|
| 456 |
+
overflow: hidden;
|
| 457 |
+
border: 1px solid #e2e8f0;
|
| 458 |
+
transition: var(--transition);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.code-card:hover {
|
| 462 |
+
transform: translateY(-2px);
|
| 463 |
+
box-shadow: var(--box-shadow-lg);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.code-header {
|
| 467 |
+
background: var(--light-gray);
|
| 468 |
+
padding: 1rem 1.5rem;
|
| 469 |
+
border-bottom: 1px solid #e2e8f0;
|
| 470 |
+
display: flex;
|
| 471 |
+
justify-content: between;
|
| 472 |
+
align-items: center;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.code-header h4 {
|
| 476 |
+
margin: 0;
|
| 477 |
+
font-size: 1.1rem;
|
| 478 |
+
color: var(--dark-color);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.code-content {
|
| 482 |
+
padding: 1.5rem;
|
| 483 |
+
background: #f8fafc;
|
| 484 |
+
margin: 0;
|
| 485 |
+
overflow-x: auto;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.code-content code {
|
| 489 |
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
| 490 |
+
font-size: 0.9rem;
|
| 491 |
+
line-height: 1.6;
|
| 492 |
+
color: var(--text-color);
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
.code-footer {
|
| 496 |
+
padding: 1rem 1.5rem;
|
| 497 |
+
background: white;
|
| 498 |
+
border-top: 1px solid #e2e8f0;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.copy-btn {
|
| 502 |
+
font-size: 0.8rem;
|
| 503 |
+
padding: 0.25rem 0.75rem;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
/* Enhanced Use Case Cards */
|
| 507 |
+
.use-case-card {
|
| 508 |
+
background: white;
|
| 509 |
+
border-radius: var(--border-radius);
|
| 510 |
+
padding: 2rem;
|
| 511 |
+
box-shadow: var(--box-shadow);
|
| 512 |
+
transition: var(--transition);
|
| 513 |
+
border: 1px solid #e2e8f0;
|
| 514 |
+
height: 100%;
|
| 515 |
+
text-align: center;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.use-case-card:hover {
|
| 519 |
+
transform: translateY(-4px);
|
| 520 |
+
box-shadow: var(--box-shadow-lg);
|
| 521 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.use-case-icon {
|
| 525 |
+
width: 4rem;
|
| 526 |
+
height: 4rem;
|
| 527 |
+
background: var(--gradient-primary);
|
| 528 |
+
border-radius: 50%;
|
| 529 |
+
display: flex;
|
| 530 |
+
align-items: center;
|
| 531 |
+
justify-content: center;
|
| 532 |
+
font-size: 1.5rem;
|
| 533 |
+
color: white;
|
| 534 |
+
margin: 0 auto 1.5rem;
|
| 535 |
+
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
.use-case-title {
|
| 539 |
+
font-size: 1.25rem;
|
| 540 |
+
font-weight: 700;
|
| 541 |
+
color: var(--dark-color);
|
| 542 |
+
margin-bottom: 1rem;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.use-case-description {
|
| 546 |
+
color: var(--text-muted);
|
| 547 |
+
margin-bottom: 1.5rem;
|
| 548 |
+
line-height: 1.7;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.use-case-examples {
|
| 552 |
+
display: flex;
|
| 553 |
+
flex-wrap: wrap;
|
| 554 |
+
gap: 0.5rem;
|
| 555 |
+
justify-content: center;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.use-case-examples .badge {
|
| 559 |
+
font-size: 0.75rem;
|
| 560 |
+
padding: 0.4rem 0.8rem;
|
| 561 |
+
border-radius: 1rem;
|
| 562 |
+
background: var(--light-gray);
|
| 563 |
+
color: var(--text-color);
|
| 564 |
+
border: 1px solid #e2e8f0;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
/* Enhanced Tech Spec Cards */
|
| 568 |
+
.tech-spec-card {
|
| 569 |
+
background: white;
|
| 570 |
+
border-radius: var(--border-radius);
|
| 571 |
+
padding: 2rem;
|
| 572 |
+
box-shadow: var(--box-shadow);
|
| 573 |
+
transition: var(--transition);
|
| 574 |
+
border: 1px solid #e2e8f0;
|
| 575 |
+
height: 100%;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
.tech-spec-card:hover {
|
| 579 |
+
transform: translateY(-2px);
|
| 580 |
+
box-shadow: var(--box-shadow-lg);
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.tech-spec-icon {
|
| 584 |
+
width: 3rem;
|
| 585 |
+
height: 3rem;
|
| 586 |
+
background: var(--gradient-accent);
|
| 587 |
+
border-radius: var(--border-radius-sm);
|
| 588 |
+
display: flex;
|
| 589 |
+
align-items: center;
|
| 590 |
+
justify-content: center;
|
| 591 |
+
font-size: 1.25rem;
|
| 592 |
+
color: white;
|
| 593 |
+
margin: 0 auto 1rem;
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
.tech-spec-card h4, .tech-spec-card h5 {
|
| 597 |
+
color: var(--dark-color);
|
| 598 |
+
margin-bottom: 1.5rem;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.tech-spec-card ul {
|
| 602 |
+
list-style: none;
|
| 603 |
+
padding: 0;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.tech-spec-card li {
|
| 607 |
+
padding: 0.5rem 0;
|
| 608 |
+
color: var(--text-color);
|
| 609 |
+
border-bottom: 1px solid #f1f5f9;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
.tech-spec-card li:last-child {
|
| 613 |
+
border-bottom: none;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
/* Enhanced Validation Styles */
|
| 617 |
+
.badge {
|
| 618 |
+
font-size: 0.75em;
|
| 619 |
+
padding: 0.4em 0.8em;
|
| 620 |
+
border-radius: 1rem;
|
| 621 |
+
font-weight: 600;
|
| 622 |
+
letter-spacing: 0.025em;
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
.validation-result {
|
| 626 |
+
animation: slideDown 0.3s ease;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
@keyframes slideDown {
|
| 630 |
+
from {
|
| 631 |
+
opacity: 0;
|
| 632 |
+
transform: translateY(-10px);
|
| 633 |
+
}
|
| 634 |
+
to {
|
| 635 |
+
opacity: 1;
|
| 636 |
+
transform: translateY(0);
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
/* Enhanced Alert Styles */
|
| 641 |
+
.alert {
|
| 642 |
+
border-radius: var(--border-radius);
|
| 643 |
+
border: none;
|
| 644 |
+
box-shadow: var(--box-shadow);
|
| 645 |
+
padding: 1rem 1.5rem;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
.alert-success {
|
| 649 |
+
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(16, 185, 129, 0.05) 100%);
|
| 650 |
+
color: #065f46;
|
| 651 |
+
border-left: 4px solid var(--success-color);
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.alert-warning {
|
| 655 |
+
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(245, 158, 11, 0.05) 100%);
|
| 656 |
+
color: #92400e;
|
| 657 |
+
border-left: 4px solid var(--warning-color);
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.alert-danger {
|
| 661 |
+
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%);
|
| 662 |
+
color: #991b1b;
|
| 663 |
+
border-left: 4px solid var(--danger-color);
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.alert-info {
|
| 667 |
+
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
|
| 668 |
+
color: #1e40af;
|
| 669 |
+
border-left: 4px solid var(--info-color);
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
/* Enhanced Accordion */
|
| 673 |
+
.accordion-item {
|
| 674 |
+
border: none;
|
| 675 |
+
margin-bottom: 1rem;
|
| 676 |
+
border-radius: var(--border-radius) !important;
|
| 677 |
+
box-shadow: var(--box-shadow);
|
| 678 |
+
overflow: hidden;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.accordion-button {
|
| 682 |
+
background: white;
|
| 683 |
+
border: none;
|
| 684 |
+
padding: 1.5rem;
|
| 685 |
+
font-weight: 600;
|
| 686 |
+
color: var(--dark-color);
|
| 687 |
+
border-radius: var(--border-radius) !important;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
.accordion-button:not(.collapsed) {
|
| 691 |
+
background: var(--light-gray);
|
| 692 |
+
color: var(--primary-color);
|
| 693 |
+
box-shadow: none;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.accordion-button:focus {
|
| 697 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 698 |
+
border-color: transparent;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
.accordion-body {
|
| 702 |
+
padding: 1.5rem;
|
| 703 |
+
background: white;
|
| 704 |
+
color: var(--text-color);
|
| 705 |
+
line-height: 1.7;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
/* Enhanced CTA Buttons */
|
| 709 |
+
.cta-btn-primary, .cta-btn-secondary {
|
| 710 |
+
position: relative;
|
| 711 |
+
overflow: hidden;
|
| 712 |
+
backdrop-filter: blur(10px);
|
| 713 |
+
border-radius: var(--border-radius);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.cta-btn-primary small, .cta-btn-secondary small {
|
| 717 |
+
font-size: 0.75rem;
|
| 718 |
+
opacity: 0.9;
|
| 719 |
+
font-weight: 400;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
.cta-content {
|
| 723 |
+
position: relative;
|
| 724 |
+
z-index: 2;
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
.cta-buttons {
|
| 728 |
+
margin: 2rem 0;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.cta-stats {
|
| 732 |
+
margin-top: 3rem;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.cta-stat h4 {
|
| 736 |
+
font-size: 2rem;
|
| 737 |
+
font-weight: 800;
|
| 738 |
+
margin-bottom: 0.25rem;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.cta-stat small {
|
| 742 |
+
font-size: 0.9rem;
|
| 743 |
+
opacity: 0.9;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
/* Enhanced Quick Start */
|
| 747 |
+
.quick-start-cta {
|
| 748 |
+
background: white;
|
| 749 |
+
border-radius: var(--border-radius-lg);
|
| 750 |
+
padding: 3rem;
|
| 751 |
+
box-shadow: var(--box-shadow-lg);
|
| 752 |
+
text-align: center;
|
| 753 |
+
border: 1px solid #e2e8f0;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.quick-start-cta h4 {
|
| 757 |
+
color: var(--dark-color);
|
| 758 |
+
margin-bottom: 1.5rem;
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
/* Enhanced Batch Processing */
|
| 762 |
+
.batch-chunk-card {
|
| 763 |
+
transition: var(--transition);
|
| 764 |
+
border: 1px solid #e2e8f0;
|
| 765 |
+
border-radius: var(--border-radius);
|
| 766 |
+
overflow: hidden;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.batch-chunk-card:hover {
|
| 770 |
+
transform: translateY(-2px);
|
| 771 |
+
box-shadow: var(--box-shadow-lg);
|
| 772 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
.batch-chunk-card .card-body {
|
| 776 |
+
padding: 1.5rem;
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
.batch-chunk-card .card-title {
|
| 780 |
+
font-size: 1rem;
|
| 781 |
+
font-weight: 600;
|
| 782 |
+
color: var(--dark-color);
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
.batch-chunk-card .card-text {
|
| 786 |
+
color: var(--text-muted);
|
| 787 |
+
line-height: 1.6;
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
.download-chunk {
|
| 791 |
+
transition: var(--transition-fast);
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.download-chunk:hover {
|
| 795 |
+
transform: scale(1.1);
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
/* Enhanced Navigation */
|
| 799 |
+
.navbar {
|
| 800 |
+
backdrop-filter: blur(10px);
|
| 801 |
+
background: rgba(255, 255, 255, 0.95) !important;
|
| 802 |
+
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
|
| 803 |
+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
.navbar-brand {
|
| 807 |
+
font-weight: 800;
|
| 808 |
+
font-size: 1.5rem;
|
| 809 |
+
color: var(--primary-color) !important;
|
| 810 |
+
transition: var(--transition);
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
.navbar-brand:hover {
|
| 814 |
+
transform: scale(1.05);
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.navbar-nav .nav-link {
|
| 818 |
+
font-weight: 500;
|
| 819 |
+
transition: var(--transition);
|
| 820 |
+
color: var(--text-color) !important;
|
| 821 |
+
position: relative;
|
| 822 |
+
padding: 0.75rem 1rem !important;
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
.navbar-nav .nav-link::after {
|
| 826 |
+
content: '';
|
| 827 |
+
position: absolute;
|
| 828 |
+
bottom: 0;
|
| 829 |
+
left: 50%;
|
| 830 |
+
width: 0;
|
| 831 |
+
height: 2px;
|
| 832 |
+
background: var(--gradient-primary);
|
| 833 |
+
transition: var(--transition);
|
| 834 |
+
transform: translateX(-50%);
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
.navbar-nav .nav-link:hover::after {
|
| 838 |
+
width: 80%;
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
.navbar-nav .nav-link:hover {
|
| 842 |
+
color: var(--primary-color) !important;
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
.navbar-text {
|
| 846 |
+
color: var(--text-muted) !important;
|
| 847 |
+
font-weight: 500;
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
/* Enhanced Footer */
|
| 851 |
+
.footer {
|
| 852 |
+
background: linear-gradient(135deg, var(--dark-color) 0%, #2d3748 100%);
|
| 853 |
+
color: white;
|
| 854 |
+
padding: 3rem 0 2rem;
|
| 855 |
+
margin-top: 6rem;
|
| 856 |
+
position: relative;
|
| 857 |
+
overflow: hidden;
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
.footer::before {
|
| 861 |
+
content: '';
|
| 862 |
+
position: absolute;
|
| 863 |
+
top: 0;
|
| 864 |
+
left: 0;
|
| 865 |
+
right: 0;
|
| 866 |
+
bottom: 0;
|
| 867 |
+
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="footer-pattern" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="10" cy="10" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23footer-pattern)"/></svg>');
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.footer h5 {
|
| 871 |
+
color: white;
|
| 872 |
+
font-weight: 700;
|
| 873 |
+
margin-bottom: 1rem;
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
.footer p, .footer a {
|
| 877 |
+
color: rgba(255, 255, 255, 0.8);
|
| 878 |
+
transition: var(--transition);
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
.footer a:hover {
|
| 882 |
+
color: white;
|
| 883 |
+
text-decoration: none;
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
/* Enhanced Responsive Design */
|
| 887 |
+
@media (max-width: 1200px) {
|
| 888 |
+
.hero-section {
|
| 889 |
+
padding: 4rem 0;
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
.floating-icon-container {
|
| 893 |
+
width: 250px;
|
| 894 |
+
height: 250px;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
.floating-icon {
|
| 898 |
+
width: 50px;
|
| 899 |
+
height: 50px;
|
| 900 |
+
font-size: 1.25rem;
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
.hero-main-icon {
|
| 904 |
+
width: 100px;
|
| 905 |
+
height: 100px;
|
| 906 |
+
font-size: 2.5rem;
|
| 907 |
+
}
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
@media (max-width: 992px) {
|
| 911 |
+
.hero-section {
|
| 912 |
+
padding: 3rem 0;
|
| 913 |
+
min-height: auto;
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
.display-3 {
|
| 917 |
+
font-size: 2.5rem;
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
.features-section, .stats-section, .quick-start-section,
|
| 921 |
+
.use-cases-section, .tech-specs-section, .faq-section,
|
| 922 |
+
.final-cta-section {
|
| 923 |
+
padding: 4rem 0;
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
.floating-icon-container {
|
| 927 |
+
display: none;
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
.hero-visual {
|
| 931 |
+
margin-top: 2rem;
|
| 932 |
+
}
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
@media (max-width: 768px) {
|
| 936 |
+
.hero-section {
|
| 937 |
+
padding: 2rem 0;
|
| 938 |
+
text-align: center;
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
.display-3 {
|
| 942 |
+
font-size: 2rem;
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
.lead {
|
| 946 |
+
font-size: 1rem;
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
.btn-lg {
|
| 950 |
+
padding: 0.75rem 1.5rem;
|
| 951 |
+
font-size: 1rem;
|
| 952 |
+
width: 100%;
|
| 953 |
+
margin-bottom: 1rem;
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
.hero-stats .col-4 {
|
| 957 |
+
margin-bottom: 1rem;
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
.stat-item h3 {
|
| 961 |
+
font-size: 2rem;
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
.features-section, .stats-section, .quick-start-section,
|
| 965 |
+
.use-cases-section, .tech-specs-section, .faq-section,
|
| 966 |
+
.final-cta-section {
|
| 967 |
+
padding: 3rem 0;
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
.feature-card-enhanced, .use-case-card, .tech-spec-card {
|
| 971 |
+
margin-bottom: 2rem;
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
.code-card {
|
| 975 |
+
margin-bottom: 1.5rem;
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
.code-header {
|
| 979 |
+
flex-direction: column;
|
| 980 |
+
gap: 1rem;
|
| 981 |
+
text-align: center;
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.quick-start-cta {
|
| 985 |
+
padding: 2rem 1rem;
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
.cta-buttons .btn {
|
| 989 |
+
width: 100%;
|
| 990 |
+
margin-bottom: 1rem;
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
.navbar-nav {
|
| 994 |
+
text-align: center;
|
| 995 |
+
padding: 1rem 0;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
.toc {
|
| 999 |
+
position: static;
|
| 1000 |
+
margin-bottom: 2rem;
|
| 1001 |
+
max-height: none;
|
| 1002 |
+
}
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
@media (max-width: 576px) {
|
| 1006 |
+
.container {
|
| 1007 |
+
padding-left: 1rem;
|
| 1008 |
+
padding-right: 1rem;
|
| 1009 |
+
}
|
| 1010 |
+
|
| 1011 |
+
.hero-section {
|
| 1012 |
+
padding: 1.5rem 0;
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
.display-3 {
|
| 1016 |
+
font-size: 1.75rem;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
.card-body {
|
| 1020 |
+
padding: 1.5rem;
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
.feature-card-enhanced, .use-case-card, .tech-spec-card {
|
| 1024 |
+
padding: 1.5rem;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
.stat-number {
|
| 1028 |
+
font-size: 2.5rem;
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
.hero-main-icon {
|
| 1032 |
+
width: 80px;
|
| 1033 |
+
height: 80px;
|
| 1034 |
+
font-size: 2rem;
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
.pulse-ring {
|
| 1038 |
+
width: 100px;
|
| 1039 |
+
height: 100px;
|
| 1040 |
+
}
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
/* Enhanced Accessibility */
|
| 1044 |
+
.btn:focus,
|
| 1045 |
+
.form-control:focus,
|
| 1046 |
+
.form-select:focus,
|
| 1047 |
+
.form-check-input:focus {
|
| 1048 |
+
outline: 3px solid rgba(99, 102, 241, 0.3);
|
| 1049 |
+
outline-offset: 2px;
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
.btn:focus-visible,
|
| 1053 |
+
.form-control:focus-visible,
|
| 1054 |
+
.form-select:focus-visible {
|
| 1055 |
+
outline: 3px solid var(--primary-color);
|
| 1056 |
+
outline-offset: 2px;
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
/* Skip to content link for screen readers */
|
| 1060 |
+
.skip-link {
|
| 1061 |
+
position: absolute;
|
| 1062 |
+
top: -40px;
|
| 1063 |
+
left: 6px;
|
| 1064 |
+
background: var(--primary-color);
|
| 1065 |
+
color: white;
|
| 1066 |
+
padding: 8px;
|
| 1067 |
+
text-decoration: none;
|
| 1068 |
+
border-radius: 4px;
|
| 1069 |
+
z-index: 1000;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
.skip-link:focus {
|
| 1073 |
+
top: 6px;
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
/* Enhanced Animation Classes */
|
| 1077 |
+
.fade-in {
|
| 1078 |
+
animation: fadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
@keyframes fadeIn {
|
| 1082 |
+
from {
|
| 1083 |
+
opacity: 0;
|
| 1084 |
+
transform: translateY(10px);
|
| 1085 |
+
}
|
| 1086 |
+
to {
|
| 1087 |
+
opacity: 1;
|
| 1088 |
+
transform: translateY(0);
|
| 1089 |
+
}
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
.slide-up {
|
| 1093 |
+
animation: slideUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
@keyframes slideUp {
|
| 1097 |
+
from {
|
| 1098 |
+
opacity: 0;
|
| 1099 |
+
transform: translateY(30px);
|
| 1100 |
+
}
|
| 1101 |
+
to {
|
| 1102 |
+
opacity: 1;
|
| 1103 |
+
transform: translateY(0);
|
| 1104 |
+
}
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
.scale-in {
|
| 1108 |
+
animation: scaleIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
@keyframes scaleIn {
|
| 1112 |
+
from {
|
| 1113 |
+
opacity: 0;
|
| 1114 |
+
transform: scale(0.9);
|
| 1115 |
+
}
|
| 1116 |
+
to {
|
| 1117 |
+
opacity: 1;
|
| 1118 |
+
transform: scale(1);
|
| 1119 |
+
}
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
/* Enhanced Utility Classes */
|
| 1123 |
+
.text-gradient {
|
| 1124 |
+
background: var(--gradient-primary);
|
| 1125 |
+
-webkit-background-clip: text;
|
| 1126 |
+
-webkit-text-fill-color: transparent;
|
| 1127 |
+
background-clip: text;
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
.text-gradient-secondary {
|
| 1131 |
+
background: var(--gradient-secondary);
|
| 1132 |
+
-webkit-background-clip: text;
|
| 1133 |
+
-webkit-text-fill-color: transparent;
|
| 1134 |
+
background-clip: text;
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
.shadow-custom {
|
| 1138 |
+
box-shadow: var(--box-shadow);
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
.shadow-lg-custom {
|
| 1142 |
+
box-shadow: var(--box-shadow-lg);
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
.shadow-xl-custom {
|
| 1146 |
+
box-shadow: var(--box-shadow-xl);
|
| 1147 |
+
}
|
| 1148 |
+
|
| 1149 |
+
.border-radius-custom {
|
| 1150 |
+
border-radius: var(--border-radius);
|
| 1151 |
+
}
|
| 1152 |
+
|
| 1153 |
+
.bg-gradient-primary {
|
| 1154 |
+
background: var(--gradient-primary);
|
| 1155 |
+
}
|
| 1156 |
+
|
| 1157 |
+
.bg-gradient-secondary {
|
| 1158 |
+
background: var(--gradient-secondary);
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
.bg-gradient-accent {
|
| 1162 |
+
background: var(--gradient-accent);
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
/* Enhanced Progress Indicators */
|
| 1166 |
+
.progress-custom {
|
| 1167 |
+
height: 10px;
|
| 1168 |
+
border-radius: var(--border-radius-sm);
|
| 1169 |
+
background-color: #e2e8f0;
|
| 1170 |
+
overflow: hidden;
|
| 1171 |
+
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 1172 |
+
}
|
| 1173 |
+
|
| 1174 |
+
.progress-bar-custom {
|
| 1175 |
+
height: 100%;
|
| 1176 |
+
background: var(--gradient-primary);
|
| 1177 |
+
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1178 |
+
position: relative;
|
| 1179 |
+
overflow: hidden;
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
.progress-bar-custom::after {
|
| 1183 |
+
content: '';
|
| 1184 |
+
position: absolute;
|
| 1185 |
+
top: 0;
|
| 1186 |
+
left: 0;
|
| 1187 |
+
right: 0;
|
| 1188 |
+
bottom: 0;
|
| 1189 |
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
| 1190 |
+
animation: progress-shimmer 2s infinite;
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
@keyframes progress-shimmer {
|
| 1194 |
+
0% { transform: translateX(-100%); }
|
| 1195 |
+
100% { transform: translateX(100%); }
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
/* Enhanced Tooltip */
|
| 1199 |
+
.tooltip-inner {
|
| 1200 |
+
background-color: var(--dark-color);
|
| 1201 |
+
border-radius: var(--border-radius-sm);
|
| 1202 |
+
font-size: 0.875rem;
|
| 1203 |
+
padding: 0.5rem 0.75rem;
|
| 1204 |
+
box-shadow: var(--box-shadow);
|
| 1205 |
+
}
|
| 1206 |
+
|
| 1207 |
+
/* Enhanced Custom Scrollbar */
|
| 1208 |
+
::-webkit-scrollbar {
|
| 1209 |
+
width: 10px;
|
| 1210 |
+
height: 10px;
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
::-webkit-scrollbar-track {
|
| 1214 |
+
background: var(--light-gray);
|
| 1215 |
+
border-radius: var(--border-radius-sm);
|
| 1216 |
+
}
|
| 1217 |
+
|
| 1218 |
+
::-webkit-scrollbar-thumb {
|
| 1219 |
+
background: var(--gradient-primary);
|
| 1220 |
+
border-radius: var(--border-radius-sm);
|
| 1221 |
+
border: 2px solid var(--light-gray);
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
::-webkit-scrollbar-thumb:hover {
|
| 1225 |
+
background: var(--gradient-secondary);
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
::-webkit-scrollbar-corner {
|
| 1229 |
+
background: var(--light-gray);
|
| 1230 |
+
}
|
| 1231 |
+
|
| 1232 |
+
/* Print Styles */
|
| 1233 |
+
@media print {
|
| 1234 |
+
.navbar, .footer, .hero-scroll-indicator, .floating-icon-container {
|
| 1235 |
+
display: none !important;
|
| 1236 |
+
}
|
| 1237 |
+
|
| 1238 |
+
.hero-section {
|
| 1239 |
+
background: white !important;
|
| 1240 |
+
color: black !important;
|
| 1241 |
+
padding: 1rem 0 !important;
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
+
.card {
|
| 1245 |
+
box-shadow: none !important;
|
| 1246 |
+
border: 1px solid #ddd !important;
|
| 1247 |
+
}
|
| 1248 |
+
|
| 1249 |
+
.btn {
|
| 1250 |
+
border: 1px solid #ddd !important;
|
| 1251 |
+
background: white !important;
|
| 1252 |
+
color: black !important;
|
| 1253 |
+
}
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
/* Playground-Specific Styles */
|
| 1257 |
+
.playground-visual {
|
| 1258 |
+
position: relative;
|
| 1259 |
+
display: flex;
|
| 1260 |
+
justify-content: center;
|
| 1261 |
+
align-items: center;
|
| 1262 |
+
height: 200px;
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
.playground-icon {
|
| 1266 |
+
width: 100px;
|
| 1267 |
+
height: 100px;
|
| 1268 |
+
background: rgba(255, 255, 255, 0.15);
|
| 1269 |
+
border-radius: 50%;
|
| 1270 |
+
display: flex;
|
| 1271 |
+
align-items: center;
|
| 1272 |
+
justify-content: center;
|
| 1273 |
+
font-size: 2.5rem;
|
| 1274 |
+
color: white;
|
| 1275 |
+
backdrop-filter: blur(20px);
|
| 1276 |
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
| 1277 |
+
position: relative;
|
| 1278 |
+
}
|
| 1279 |
+
|
| 1280 |
+
.audio-player-container {
|
| 1281 |
+
border: 2px solid #e2e8f0;
|
| 1282 |
+
transition: var(--transition);
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
.audio-player-container:hover {
|
| 1286 |
+
border-color: var(--primary-color);
|
| 1287 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
.stat-item {
|
| 1291 |
+
padding: 1rem;
|
| 1292 |
+
text-align: center;
|
| 1293 |
+
}
|
| 1294 |
+
|
| 1295 |
+
.stat-item i {
|
| 1296 |
+
font-size: 1.5rem;
|
| 1297 |
+
margin-bottom: 0.5rem;
|
| 1298 |
+
display: block;
|
| 1299 |
+
}
|
| 1300 |
+
|
| 1301 |
+
.stat-value {
|
| 1302 |
+
font-size: 1.25rem;
|
| 1303 |
+
font-weight: 700;
|
| 1304 |
+
color: var(--dark-color);
|
| 1305 |
+
margin-bottom: 0.25rem;
|
| 1306 |
+
}
|
| 1307 |
+
|
| 1308 |
+
.stat-label {
|
| 1309 |
+
font-size: 0.875rem;
|
| 1310 |
+
color: var(--text-muted);
|
| 1311 |
+
font-weight: 500;
|
| 1312 |
+
}
|
| 1313 |
+
|
| 1314 |
+
.card-header {
|
| 1315 |
+
border-bottom: none;
|
| 1316 |
+
border-radius: var(--border-radius) var(--border-radius) 0 0 !important;
|
| 1317 |
+
}
|
| 1318 |
+
|
| 1319 |
+
/* Enhanced Form Controls for Playground */
|
| 1320 |
+
.playground .form-control,
|
| 1321 |
+
.playground .form-select {
|
| 1322 |
+
border: 2px solid #e2e8f0;
|
| 1323 |
+
border-radius: var(--border-radius-sm);
|
| 1324 |
+
padding: 1rem;
|
| 1325 |
+
font-size: 1rem;
|
| 1326 |
+
transition: var(--transition);
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
+
.playground .form-control:focus,
|
| 1330 |
+
.playground .form-select:focus {
|
| 1331 |
+
border-color: var(--primary-color);
|
| 1332 |
+
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
|
| 1333 |
+
transform: translateY(-1px);
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
.playground .btn-group .btn {
|
| 1337 |
+
border-radius: var(--border-radius-sm);
|
| 1338 |
+
}
|
| 1339 |
+
|
| 1340 |
+
.playground .btn-group .btn:first-child {
|
| 1341 |
+
border-top-right-radius: 0;
|
| 1342 |
+
border-bottom-right-radius: 0;
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
.playground .btn-group .btn:last-child {
|
| 1346 |
+
border-top-left-radius: 0;
|
| 1347 |
+
border-bottom-left-radius: 0;
|
| 1348 |
+
}
|
| 1349 |
+
|
| 1350 |
+
/* Audio Player Enhancements */
|
| 1351 |
+
audio::-webkit-media-controls-panel {
|
| 1352 |
+
background-color: var(--light-gray);
|
| 1353 |
+
border-radius: var(--border-radius-sm);
|
| 1354 |
+
}
|
| 1355 |
+
|
| 1356 |
+
audio::-webkit-media-controls-play-button,
|
| 1357 |
+
audio::-webkit-media-controls-pause-button {
|
| 1358 |
+
background-color: var(--primary-color);
|
| 1359 |
+
border-radius: 50%;
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
audio::-webkit-media-controls-timeline {
|
| 1363 |
+
background-color: var(--light-gray);
|
| 1364 |
+
border-radius: var(--border-radius-sm);
|
| 1365 |
+
}
|
| 1366 |
+
|
| 1367 |
+
audio::-webkit-media-controls-current-time-display,
|
| 1368 |
+
audio::-webkit-media-controls-time-remaining-display {
|
| 1369 |
+
color: var(--text-color);
|
| 1370 |
+
font-weight: 500;
|
| 1371 |
+
}
|
| 1372 |
+
|
| 1373 |
+
/* Reduced Motion Support */
|
| 1374 |
+
@media (prefers-reduced-motion: reduce) {
|
| 1375 |
+
*,
|
| 1376 |
+
*::before,
|
| 1377 |
+
*::after {
|
| 1378 |
+
animation-duration: 0.01ms !important;
|
| 1379 |
+
animation-iteration-count: 1 !important;
|
| 1380 |
+
transition-duration: 0.01ms !important;
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
.hero-background-animation,
|
| 1384 |
+
.floating-icon,
|
| 1385 |
+
.pulse-ring,
|
| 1386 |
+
.hero-scroll-indicator,
|
| 1387 |
+
.playground-icon {
|
| 1388 |
+
animation: none !important;
|
| 1389 |
+
}
|
| 1390 |
+
}
|
ttsfm-web/static/js/playground.js
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// TTSFM Playground JavaScript
|
| 2 |
+
|
| 3 |
+
// Global variables
|
| 4 |
+
let currentAudioBlob = null;
|
| 5 |
+
let currentFormat = 'mp3';
|
| 6 |
+
let batchResults = [];
|
| 7 |
+
|
| 8 |
+
// Initialize playground
|
| 9 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 10 |
+
initializePlayground();
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
function initializePlayground() {
|
| 14 |
+
loadVoices();
|
| 15 |
+
loadFormats();
|
| 16 |
+
updateCharCount();
|
| 17 |
+
setupEventListeners();
|
| 18 |
+
|
| 19 |
+
// Initialize tooltips if Bootstrap is available
|
| 20 |
+
if (typeof bootstrap !== 'undefined') {
|
| 21 |
+
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
| 22 |
+
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
| 23 |
+
return new bootstrap.Tooltip(tooltipTriggerEl);
|
| 24 |
+
});
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function setupEventListeners() {
|
| 29 |
+
// Form and input events
|
| 30 |
+
document.getElementById('text-input').addEventListener('input', updateCharCount);
|
| 31 |
+
document.getElementById('tts-form').addEventListener('submit', generateSpeech);
|
| 32 |
+
document.getElementById('max-length-input').addEventListener('input', updateCharCount);
|
| 33 |
+
document.getElementById('auto-split-check').addEventListener('change', updateGenerateButton);
|
| 34 |
+
|
| 35 |
+
// Enhanced button events
|
| 36 |
+
document.getElementById('validate-text-btn').addEventListener('click', validateText);
|
| 37 |
+
document.getElementById('random-text-btn').addEventListener('click', loadRandomText);
|
| 38 |
+
document.getElementById('download-btn').addEventListener('click', downloadAudio);
|
| 39 |
+
document.getElementById('download-all-btn').addEventListener('click', downloadAllAudio);
|
| 40 |
+
|
| 41 |
+
// New button events
|
| 42 |
+
const clearTextBtn = document.getElementById('clear-text-btn');
|
| 43 |
+
if (clearTextBtn) {
|
| 44 |
+
clearTextBtn.addEventListener('click', clearText);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
const resetFormBtn = document.getElementById('reset-form-btn');
|
| 50 |
+
if (resetFormBtn) {
|
| 51 |
+
resetFormBtn.addEventListener('click', resetForm);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const replayBtn = document.getElementById('replay-btn');
|
| 55 |
+
if (replayBtn) {
|
| 56 |
+
replayBtn.addEventListener('click', replayAudio);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const shareBtn = document.getElementById('share-btn');
|
| 60 |
+
if (shareBtn) {
|
| 61 |
+
shareBtn.addEventListener('click', shareAudio);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Voice and format selection events
|
| 65 |
+
document.getElementById('voice-select').addEventListener('change', updateVoiceInfo);
|
| 66 |
+
document.getElementById('format-select').addEventListener('change', updateFormatInfo);
|
| 67 |
+
|
| 68 |
+
// Example text buttons
|
| 69 |
+
document.querySelectorAll('.use-example').forEach(button => {
|
| 70 |
+
button.addEventListener('click', function() {
|
| 71 |
+
document.getElementById('text-input').value = this.dataset.text;
|
| 72 |
+
updateCharCount();
|
| 73 |
+
// Add visual feedback
|
| 74 |
+
this.classList.add('btn-success');
|
| 75 |
+
setTimeout(() => {
|
| 76 |
+
this.classList.remove('btn-success');
|
| 77 |
+
this.classList.add('btn-outline-primary');
|
| 78 |
+
}, 1000);
|
| 79 |
+
});
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
// Keyboard shortcuts
|
| 83 |
+
document.addEventListener('keydown', function(e) {
|
| 84 |
+
// Ctrl/Cmd + Enter to generate speech
|
| 85 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 86 |
+
e.preventDefault();
|
| 87 |
+
document.getElementById('generate-btn').click();
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Escape to clear results
|
| 91 |
+
if (e.key === 'Escape') {
|
| 92 |
+
clearResults();
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async function loadVoices() {
|
| 98 |
+
try {
|
| 99 |
+
const response = await fetch('/api/voices');
|
| 100 |
+
const data = await response.json();
|
| 101 |
+
|
| 102 |
+
const select = document.getElementById('voice-select');
|
| 103 |
+
select.innerHTML = '';
|
| 104 |
+
|
| 105 |
+
data.voices.forEach(voice => {
|
| 106 |
+
const option = document.createElement('option');
|
| 107 |
+
option.value = voice.id;
|
| 108 |
+
option.textContent = `${voice.name} - ${voice.description}`;
|
| 109 |
+
select.appendChild(option);
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
// Select default voice
|
| 113 |
+
select.value = 'alloy';
|
| 114 |
+
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error('Failed to load voices:', error);
|
| 117 |
+
console.log('Failed to load voices. Please refresh the page.');
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
async function loadFormats() {
|
| 122 |
+
try {
|
| 123 |
+
const response = await fetch('/api/formats');
|
| 124 |
+
const data = await response.json();
|
| 125 |
+
|
| 126 |
+
const select = document.getElementById('format-select');
|
| 127 |
+
select.innerHTML = '';
|
| 128 |
+
|
| 129 |
+
data.formats.forEach(format => {
|
| 130 |
+
const option = document.createElement('option');
|
| 131 |
+
option.value = format.id;
|
| 132 |
+
option.textContent = `${format.name} - ${format.description}`;
|
| 133 |
+
select.appendChild(option);
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
// Select default format
|
| 137 |
+
select.value = 'mp3';
|
| 138 |
+
updateFormatInfo();
|
| 139 |
+
|
| 140 |
+
} catch (error) {
|
| 141 |
+
console.error('Failed to load formats:', error);
|
| 142 |
+
console.log('Failed to load formats. Please refresh the page.');
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function updateCharCount() {
|
| 147 |
+
const text = document.getElementById('text-input').value;
|
| 148 |
+
const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
|
| 149 |
+
const charCount = text.length;
|
| 150 |
+
|
| 151 |
+
document.getElementById('char-count').textContent = charCount.toLocaleString();
|
| 152 |
+
|
| 153 |
+
// Update length status with better visual feedback
|
| 154 |
+
const statusElement = document.getElementById('length-status');
|
| 155 |
+
const percentage = (charCount / maxLength) * 100;
|
| 156 |
+
|
| 157 |
+
if (charCount > maxLength) {
|
| 158 |
+
statusElement.innerHTML = '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>Exceeds limit</span>';
|
| 159 |
+
} else if (percentage > 80) {
|
| 160 |
+
statusElement.innerHTML = '<span class="badge bg-warning"><i class="fas fa-exclamation me-1"></i>Near limit</span>';
|
| 161 |
+
} else if (percentage > 50) {
|
| 162 |
+
statusElement.innerHTML = '<span class="badge bg-info"><i class="fas fa-info me-1"></i>Good</span>';
|
| 163 |
+
} else {
|
| 164 |
+
statusElement.innerHTML = '<span class="badge bg-success"><i class="fas fa-check me-1"></i>OK</span>';
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
updateGenerateButton();
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function updateGenerateButton() {
|
| 171 |
+
const text = document.getElementById('text-input').value;
|
| 172 |
+
const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
|
| 173 |
+
const autoSplit = document.getElementById('auto-split-check').checked;
|
| 174 |
+
const generateBtn = document.getElementById('generate-btn');
|
| 175 |
+
const btnText = generateBtn.querySelector('.btn-text');
|
| 176 |
+
|
| 177 |
+
if (text.length > maxLength && autoSplit) {
|
| 178 |
+
btnText.innerHTML = '<i class="fas fa-layer-group me-2"></i>Generate Speech (Batch Mode)';
|
| 179 |
+
generateBtn.classList.add('btn-warning');
|
| 180 |
+
generateBtn.classList.remove('btn-primary');
|
| 181 |
+
} else {
|
| 182 |
+
btnText.innerHTML = '<i class="fas fa-magic me-2"></i>Generate Speech';
|
| 183 |
+
generateBtn.classList.add('btn-primary');
|
| 184 |
+
generateBtn.classList.remove('btn-warning');
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
async function validateText() {
|
| 189 |
+
const text = document.getElementById('text-input').value.trim();
|
| 190 |
+
const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
|
| 191 |
+
|
| 192 |
+
if (!text) {
|
| 193 |
+
console.log('Please enter some text to validate');
|
| 194 |
+
return;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const validateBtn = document.getElementById('validate-text-btn');
|
| 198 |
+
setLoading(validateBtn, true);
|
| 199 |
+
|
| 200 |
+
try {
|
| 201 |
+
const response = await fetch('/api/validate-text', {
|
| 202 |
+
method: 'POST',
|
| 203 |
+
headers: { 'Content-Type': 'application/json' },
|
| 204 |
+
body: JSON.stringify({ text, max_length: maxLength })
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
const data = await response.json();
|
| 208 |
+
const resultDiv = document.getElementById('validation-result');
|
| 209 |
+
|
| 210 |
+
if (data.is_valid) {
|
| 211 |
+
resultDiv.innerHTML = `
|
| 212 |
+
<div class="alert alert-success fade-in">
|
| 213 |
+
<i class="fas fa-check-circle me-2"></i>
|
| 214 |
+
<strong>Text is valid!</strong> (${data.text_length.toLocaleString()} characters)
|
| 215 |
+
<div class="progress progress-custom mt-2">
|
| 216 |
+
<div class="progress-bar-custom" style="width: ${(data.text_length / data.max_length) * 100}%"></div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
`;
|
| 220 |
+
} else {
|
| 221 |
+
resultDiv.innerHTML = `
|
| 222 |
+
<div class="alert alert-warning fade-in">
|
| 223 |
+
<i class="fas fa-exclamation-triangle me-2"></i>
|
| 224 |
+
<strong>Text exceeds limit!</strong> (${data.text_length.toLocaleString()}/${data.max_length.toLocaleString()} characters)
|
| 225 |
+
<br><small class="mt-2 d-block">Suggested chunks: ${data.suggested_chunks}</small>
|
| 226 |
+
<div class="mt-3">
|
| 227 |
+
<strong>Preview of chunks:</strong>
|
| 228 |
+
<div class="mt-2">
|
| 229 |
+
${data.chunk_preview.map((chunk, i) => `
|
| 230 |
+
<div class="border rounded p-2 mb-2 bg-light">
|
| 231 |
+
<small class="text-muted">Chunk ${i+1}:</small>
|
| 232 |
+
<div class="small">${chunk}</div>
|
| 233 |
+
</div>
|
| 234 |
+
`).join('')}
|
| 235 |
+
</div>
|
| 236 |
+
<button class="btn btn-sm btn-outline-primary mt-2" onclick="enableAutoSplit()">
|
| 237 |
+
<i class="fas fa-magic me-1"></i>Enable Auto-Split
|
| 238 |
+
</button>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
`;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
resultDiv.classList.remove('d-none');
|
| 245 |
+
resultDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 246 |
+
|
| 247 |
+
} catch (error) {
|
| 248 |
+
console.error('Validation failed:', error);
|
| 249 |
+
console.log('Failed to validate text. Please try again.');
|
| 250 |
+
} finally {
|
| 251 |
+
setLoading(validateBtn, false);
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function enableAutoSplit() {
|
| 256 |
+
document.getElementById('auto-split-check').checked = true;
|
| 257 |
+
updateGenerateButton();
|
| 258 |
+
console.log('Auto-split enabled! Click Generate Speech to process in batch mode.');
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
async function generateSpeech(event) {
|
| 262 |
+
event.preventDefault();
|
| 263 |
+
|
| 264 |
+
const button = document.getElementById('generate-btn');
|
| 265 |
+
const audioResult = document.getElementById('audio-result');
|
| 266 |
+
const batchResult = document.getElementById('batch-result');
|
| 267 |
+
|
| 268 |
+
// Get form data
|
| 269 |
+
const formData = getFormData();
|
| 270 |
+
|
| 271 |
+
if (!validateFormData(formData)) {
|
| 272 |
+
return;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// Check if we need batch processing
|
| 276 |
+
const needsBatch = formData.text.length > formData.maxLength && formData.autoSplit;
|
| 277 |
+
|
| 278 |
+
// Show loading state
|
| 279 |
+
setLoading(button, true);
|
| 280 |
+
clearResults();
|
| 281 |
+
|
| 282 |
+
try {
|
| 283 |
+
if (needsBatch) {
|
| 284 |
+
await generateBatchSpeech(formData);
|
| 285 |
+
} else {
|
| 286 |
+
await generateSingleSpeech(formData);
|
| 287 |
+
}
|
| 288 |
+
} catch (error) {
|
| 289 |
+
console.error('Generation failed:', error);
|
| 290 |
+
console.log(`Failed to generate speech: ${error.message}`);
|
| 291 |
+
} finally {
|
| 292 |
+
setLoading(button, false);
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function getFormData() {
|
| 297 |
+
return {
|
| 298 |
+
text: document.getElementById('text-input').value.trim(),
|
| 299 |
+
voice: document.getElementById('voice-select').value,
|
| 300 |
+
format: document.getElementById('format-select').value,
|
| 301 |
+
instructions: document.getElementById('instructions-input').value.trim(),
|
| 302 |
+
maxLength: parseInt(document.getElementById('max-length-input').value) || 4096,
|
| 303 |
+
validateLength: document.getElementById('validate-length-check').checked,
|
| 304 |
+
autoSplit: document.getElementById('auto-split-check').checked
|
| 305 |
+
};
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function validateFormData(formData) {
|
| 309 |
+
if (!formData.text || !formData.voice || !formData.format) {
|
| 310 |
+
console.log('Please fill in all required fields');
|
| 311 |
+
return false;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
if (formData.text.length > formData.maxLength && formData.validateLength && !formData.autoSplit) {
|
| 315 |
+
console.log(`Text is too long (${formData.text.length} characters). Enable auto-split or reduce text length.`);
|
| 316 |
+
return false;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
return true;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
function clearResults() {
|
| 323 |
+
document.getElementById('audio-result').classList.add('d-none');
|
| 324 |
+
document.getElementById('batch-result').classList.add('d-none');
|
| 325 |
+
document.getElementById('validation-result').classList.add('d-none');
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Utility functions
|
| 329 |
+
function setLoading(button, loading) {
|
| 330 |
+
if (loading) {
|
| 331 |
+
button.classList.add('loading');
|
| 332 |
+
button.disabled = true;
|
| 333 |
+
} else {
|
| 334 |
+
button.classList.remove('loading');
|
| 335 |
+
button.disabled = false;
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
async function generateSingleSpeech(formData) {
|
| 342 |
+
const audioResult = document.getElementById('audio-result');
|
| 343 |
+
|
| 344 |
+
const response = await fetch('/api/generate', {
|
| 345 |
+
method: 'POST',
|
| 346 |
+
headers: { 'Content-Type': 'application/json' },
|
| 347 |
+
body: JSON.stringify({
|
| 348 |
+
text: formData.text,
|
| 349 |
+
voice: formData.voice,
|
| 350 |
+
format: formData.format,
|
| 351 |
+
instructions: formData.instructions || undefined,
|
| 352 |
+
max_length: formData.maxLength,
|
| 353 |
+
validate_length: formData.validateLength
|
| 354 |
+
})
|
| 355 |
+
});
|
| 356 |
+
|
| 357 |
+
if (!response.ok) {
|
| 358 |
+
const errorData = await response.json();
|
| 359 |
+
throw new Error(errorData.error || `HTTP ${response.status}`);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// Get audio data
|
| 363 |
+
const audioBlob = await response.blob();
|
| 364 |
+
currentAudioBlob = audioBlob;
|
| 365 |
+
currentFormat = formData.format;
|
| 366 |
+
|
| 367 |
+
// Create audio URL and setup player
|
| 368 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
| 369 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 370 |
+
audioPlayer.src = audioUrl;
|
| 371 |
+
|
| 372 |
+
// Use enhanced display function
|
| 373 |
+
displayAudioResult(audioBlob, formData.format, formData.voice, formData.text);
|
| 374 |
+
|
| 375 |
+
console.log('Speech generated successfully! Click play to listen.');
|
| 376 |
+
|
| 377 |
+
// Auto-play if user prefers
|
| 378 |
+
if (localStorage.getItem('autoPlay') === 'true') {
|
| 379 |
+
audioPlayer.play().catch(() => {
|
| 380 |
+
// Auto-play blocked, that's fine
|
| 381 |
+
});
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
async function generateBatchSpeech(formData) {
|
| 386 |
+
const batchResult = document.getElementById('batch-result');
|
| 387 |
+
|
| 388 |
+
const response = await fetch('/api/generate-batch', {
|
| 389 |
+
method: 'POST',
|
| 390 |
+
headers: { 'Content-Type': 'application/json' },
|
| 391 |
+
body: JSON.stringify({
|
| 392 |
+
text: formData.text,
|
| 393 |
+
voice: formData.voice,
|
| 394 |
+
format: formData.format,
|
| 395 |
+
instructions: formData.instructions || undefined,
|
| 396 |
+
max_length: formData.maxLength,
|
| 397 |
+
preserve_words: true
|
| 398 |
+
})
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
if (!response.ok) {
|
| 402 |
+
const errorData = await response.json();
|
| 403 |
+
throw new Error(errorData.error || `HTTP ${response.status}`);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
const data = await response.json();
|
| 407 |
+
batchResults = data.results;
|
| 408 |
+
|
| 409 |
+
// Update batch summary
|
| 410 |
+
const summaryDiv = document.getElementById('batch-summary');
|
| 411 |
+
summaryDiv.innerHTML = `
|
| 412 |
+
<i class="fas fa-layer-group me-2"></i>
|
| 413 |
+
<strong>Batch Processing Complete!</strong>
|
| 414 |
+
Generated ${data.successful_chunks} of ${data.total_chunks} audio chunks successfully.
|
| 415 |
+
${data.successful_chunks < data.total_chunks ?
|
| 416 |
+
`<br><small class="text-warning">⚠️ ${data.total_chunks - data.successful_chunks} chunks failed to generate.</small>` :
|
| 417 |
+
'<br><small class="text-success">✅ All chunks generated successfully!</small>'
|
| 418 |
+
}
|
| 419 |
+
`;
|
| 420 |
+
|
| 421 |
+
// Display chunks
|
| 422 |
+
displayBatchChunks(data.results, formData.format);
|
| 423 |
+
|
| 424 |
+
// Show batch result with animation
|
| 425 |
+
batchResult.classList.remove('d-none');
|
| 426 |
+
batchResult.classList.add('fade-in');
|
| 427 |
+
|
| 428 |
+
console.log(`Batch processing completed! Generated ${data.successful_chunks} audio files.`);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
function displayBatchChunks(results, format) {
|
| 432 |
+
const chunksDiv = document.getElementById('batch-chunks');
|
| 433 |
+
chunksDiv.innerHTML = '';
|
| 434 |
+
|
| 435 |
+
results.forEach((result, index) => {
|
| 436 |
+
const chunkDiv = document.createElement('div');
|
| 437 |
+
chunkDiv.className = 'col-md-6 col-lg-4 mb-3';
|
| 438 |
+
|
| 439 |
+
if (result.audio_data) {
|
| 440 |
+
// Convert base64 to blob
|
| 441 |
+
const audioBlob = base64ToBlob(result.audio_data, result.content_type);
|
| 442 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
| 443 |
+
|
| 444 |
+
chunkDiv.innerHTML = `
|
| 445 |
+
<div class="card batch-chunk-card h-100">
|
| 446 |
+
<div class="card-body">
|
| 447 |
+
<div class="d-flex justify-content-between align-items-start mb-2">
|
| 448 |
+
<h6 class="card-title mb-0">
|
| 449 |
+
<i class="fas fa-music me-1"></i>Chunk ${result.chunk_index}
|
| 450 |
+
</h6>
|
| 451 |
+
<span class="badge bg-success">
|
| 452 |
+
<i class="fas fa-check me-1"></i>Success
|
| 453 |
+
</span>
|
| 454 |
+
</div>
|
| 455 |
+
<p class="card-text small text-muted mb-3">${result.chunk_text}</p>
|
| 456 |
+
<audio controls class="w-100 mb-3" preload="metadata">
|
| 457 |
+
<source src="${audioUrl}" type="${result.content_type}">
|
| 458 |
+
Your browser does not support audio playback.
|
| 459 |
+
</audio>
|
| 460 |
+
<div class="d-flex justify-content-between align-items-center">
|
| 461 |
+
<small class="text-muted">
|
| 462 |
+
<i class="fas fa-file-audio me-1"></i>
|
| 463 |
+
${(result.size / 1024).toFixed(1)} KB
|
| 464 |
+
</small>
|
| 465 |
+
<button class="btn btn-sm btn-outline-primary download-chunk"
|
| 466 |
+
data-url="${audioUrl}"
|
| 467 |
+
data-filename="chunk_${result.chunk_index}.${result.format}"
|
| 468 |
+
title="Download this chunk">
|
| 469 |
+
<i class="fas fa-download"></i>
|
| 470 |
+
</button>
|
| 471 |
+
</div>
|
| 472 |
+
</div>
|
| 473 |
+
</div>
|
| 474 |
+
`;
|
| 475 |
+
} else {
|
| 476 |
+
chunkDiv.innerHTML = `
|
| 477 |
+
<div class="card border-danger h-100">
|
| 478 |
+
<div class="card-body">
|
| 479 |
+
<div class="d-flex justify-content-between align-items-start mb-2">
|
| 480 |
+
<h6 class="card-title mb-0 text-danger">
|
| 481 |
+
<i class="fas fa-exclamation-triangle me-1"></i>Chunk ${result.chunk_index}
|
| 482 |
+
</h6>
|
| 483 |
+
<span class="badge bg-danger">
|
| 484 |
+
<i class="fas fa-times me-1"></i>Failed
|
| 485 |
+
</span>
|
| 486 |
+
</div>
|
| 487 |
+
<p class="card-text small text-muted mb-3">${result.chunk_text}</p>
|
| 488 |
+
<div class="alert alert-danger small mb-0">
|
| 489 |
+
<i class="fas fa-exclamation-circle me-1"></i>
|
| 490 |
+
${result.error}
|
| 491 |
+
</div>
|
| 492 |
+
</div>
|
| 493 |
+
</div>
|
| 494 |
+
`;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
chunksDiv.appendChild(chunkDiv);
|
| 498 |
+
});
|
| 499 |
+
|
| 500 |
+
// Add download event listeners
|
| 501 |
+
document.querySelectorAll('.download-chunk').forEach(btn => {
|
| 502 |
+
btn.addEventListener('click', function() {
|
| 503 |
+
const url = this.dataset.url;
|
| 504 |
+
const filename = this.dataset.filename;
|
| 505 |
+
downloadFromUrl(url, filename);
|
| 506 |
+
|
| 507 |
+
// Visual feedback
|
| 508 |
+
const icon = this.querySelector('i');
|
| 509 |
+
icon.className = 'fas fa-check';
|
| 510 |
+
setTimeout(() => {
|
| 511 |
+
icon.className = 'fas fa-download';
|
| 512 |
+
}, 1000);
|
| 513 |
+
});
|
| 514 |
+
});
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
function downloadAudio() {
|
| 518 |
+
if (!currentAudioBlob) {
|
| 519 |
+
console.log('No audio to download');
|
| 520 |
+
return;
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
const url = URL.createObjectURL(currentAudioBlob);
|
| 524 |
+
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
| 525 |
+
downloadFromUrl(url, `ttsfm-speech-${timestamp}.${currentFormat}`);
|
| 526 |
+
URL.revokeObjectURL(url);
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
function downloadAllAudio() {
|
| 530 |
+
const downloadButtons = document.querySelectorAll('.download-chunk');
|
| 531 |
+
if (downloadButtons.length === 0) {
|
| 532 |
+
console.log('No batch audio files to download');
|
| 533 |
+
return;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
console.log(`Starting download of ${downloadButtons.length} files...`);
|
| 537 |
+
|
| 538 |
+
downloadButtons.forEach((btn, index) => {
|
| 539 |
+
setTimeout(() => {
|
| 540 |
+
btn.click();
|
| 541 |
+
}, index * 500); // Stagger downloads to avoid browser limits
|
| 542 |
+
});
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
function base64ToBlob(base64, contentType) {
|
| 546 |
+
const byteCharacters = atob(base64);
|
| 547 |
+
const byteNumbers = new Array(byteCharacters.length);
|
| 548 |
+
for (let i = 0; i < byteCharacters.length; i++) {
|
| 549 |
+
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
| 550 |
+
}
|
| 551 |
+
const byteArray = new Uint8Array(byteNumbers);
|
| 552 |
+
return new Blob([byteArray], { type: contentType });
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
function downloadFromUrl(url, filename) {
|
| 556 |
+
const a = document.createElement('a');
|
| 557 |
+
a.href = url;
|
| 558 |
+
a.download = filename;
|
| 559 |
+
a.style.display = 'none';
|
| 560 |
+
document.body.appendChild(a);
|
| 561 |
+
a.click();
|
| 562 |
+
document.body.removeChild(a);
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
// New enhanced functions
|
| 566 |
+
function clearText() {
|
| 567 |
+
document.getElementById('text-input').value = '';
|
| 568 |
+
updateCharCount();
|
| 569 |
+
clearResults();
|
| 570 |
+
console.log('Text cleared successfully');
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
function loadRandomText() {
|
| 574 |
+
const randomTexts = [
|
| 575 |
+
// News & Information
|
| 576 |
+
"Breaking news: Scientists have discovered a revolutionary new method for generating incredibly natural synthetic speech using advanced neural networks and machine learning algorithms.",
|
| 577 |
+
"Weather update: Today will be partly cloudy with temperatures reaching 75 degrees Fahrenheit. Light winds from the southwest at 5 to 10 miles per hour.",
|
| 578 |
+
"Technology report: The latest advancements in artificial intelligence are revolutionizing how we interact with digital devices and services.",
|
| 579 |
+
|
| 580 |
+
// Educational & Informative
|
| 581 |
+
"The human brain contains approximately 86 billion neurons, each connected to thousands of others, creating a complex network that enables consciousness, memory, and thought.",
|
| 582 |
+
"Photosynthesis is the process by which plants convert sunlight, carbon dioxide, and water into glucose and oxygen, forming the foundation of most life on Earth.",
|
| 583 |
+
"The speed of light in a vacuum is exactly 299,792,458 meters per second, making it one of the fundamental constants of physics.",
|
| 584 |
+
|
| 585 |
+
// Creative & Storytelling
|
| 586 |
+
"Once upon a time, in a land far away, there lived a wise old wizard who could speak to the stars and understand their ancient secrets.",
|
| 587 |
+
"The mysterious lighthouse stood alone on the rocky cliff, its beacon cutting through the fog like a sword of light, guiding lost ships safely home.",
|
| 588 |
+
"In the depths of the enchanted forest, where sunbeams danced through emerald leaves, a young adventurer discovered a hidden path to destiny.",
|
| 589 |
+
|
| 590 |
+
// Business & Professional
|
| 591 |
+
"Our quarterly results demonstrate strong growth across all market segments, with revenue increasing by 23% compared to the same period last year.",
|
| 592 |
+
"The new product launch exceeded expectations, capturing 15% market share within the first six months and establishing our brand as an industry leader.",
|
| 593 |
+
"We are committed to sustainable business practices that benefit our customers, employees, and the environment for generations to come.",
|
| 594 |
+
|
| 595 |
+
// Technical & Programming
|
| 596 |
+
"The TTSFM package provides a comprehensive API for text-to-speech generation with support for multiple voices and audio formats.",
|
| 597 |
+
"Machine learning algorithms process vast amounts of data to identify patterns and make predictions with remarkable accuracy.",
|
| 598 |
+
"Cloud computing has transformed how businesses store, process, and access their data, enabling scalability and flexibility like never before.",
|
| 599 |
+
|
| 600 |
+
// Conversational & Casual
|
| 601 |
+
"Welcome to TTSFM! Experience the future of text-to-speech technology with our premium AI voices.",
|
| 602 |
+
"Good morning! Today is a beautiful day to learn something new and explore the possibilities of text-to-speech technology.",
|
| 603 |
+
"Have you ever wondered what it would be like if your computer could speak with perfect human-like intonation and emotion?"
|
| 604 |
+
];
|
| 605 |
+
|
| 606 |
+
const randomText = randomTexts[Math.floor(Math.random() * randomTexts.length)];
|
| 607 |
+
document.getElementById('text-input').value = randomText;
|
| 608 |
+
updateCharCount();
|
| 609 |
+
console.log('Random text loaded successfully');
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
function resetForm() {
|
| 615 |
+
// Reset form to default values
|
| 616 |
+
document.getElementById('text-input').value = 'Welcome to TTSFM! Experience the future of text-to-speech technology with our premium AI voices. Generate natural, expressive speech for any application.';
|
| 617 |
+
document.getElementById('voice-select').value = 'alloy';
|
| 618 |
+
document.getElementById('format-select').value = 'mp3';
|
| 619 |
+
document.getElementById('instructions-input').value = '';
|
| 620 |
+
document.getElementById('max-length-input').value = '4096';
|
| 621 |
+
document.getElementById('validate-length-check').checked = true;
|
| 622 |
+
document.getElementById('auto-split-check').checked = false;
|
| 623 |
+
|
| 624 |
+
updateCharCount();
|
| 625 |
+
updateGenerateButton();
|
| 626 |
+
clearResults();
|
| 627 |
+
console.log('Form reset to default values');
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
function replayAudio() {
|
| 631 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 632 |
+
if (audioPlayer && audioPlayer.src) {
|
| 633 |
+
audioPlayer.currentTime = 0;
|
| 634 |
+
audioPlayer.play().catch(() => {
|
| 635 |
+
console.log('Unable to replay audio. Please check your browser settings.');
|
| 636 |
+
});
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
function shareAudio() {
|
| 641 |
+
if (navigator.share && currentAudioBlob) {
|
| 642 |
+
const file = new File([currentAudioBlob], `ttsfm-speech.${currentFormat}`, {
|
| 643 |
+
type: `audio/${currentFormat}`
|
| 644 |
+
});
|
| 645 |
+
|
| 646 |
+
navigator.share({
|
| 647 |
+
title: 'TTSFM Generated Speech',
|
| 648 |
+
text: 'Check out this speech generated with TTSFM!',
|
| 649 |
+
files: [file]
|
| 650 |
+
}).catch(() => {
|
| 651 |
+
// Fallback to copying link
|
| 652 |
+
copyAudioLink();
|
| 653 |
+
});
|
| 654 |
+
} else {
|
| 655 |
+
copyAudioLink();
|
| 656 |
+
}
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
function copyAudioLink() {
|
| 660 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 661 |
+
if (audioPlayer && audioPlayer.src) {
|
| 662 |
+
navigator.clipboard.writeText(audioPlayer.src).then(() => {
|
| 663 |
+
console.log('Audio link copied to clipboard!');
|
| 664 |
+
}).catch(() => {
|
| 665 |
+
console.log('Unable to copy link. Please try downloading the audio instead.');
|
| 666 |
+
});
|
| 667 |
+
}
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
function updateVoiceInfo() {
|
| 671 |
+
const voiceSelect = document.getElementById('voice-select');
|
| 672 |
+
const previewBtn = document.getElementById('preview-voice-btn');
|
| 673 |
+
|
| 674 |
+
if (voiceSelect.value) {
|
| 675 |
+
previewBtn.disabled = false;
|
| 676 |
+
previewBtn.onclick = () => previewVoice(voiceSelect.value);
|
| 677 |
+
} else {
|
| 678 |
+
previewBtn.disabled = true;
|
| 679 |
+
}
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
function updateFormatInfo() {
|
| 683 |
+
const formatSelect = document.getElementById('format-select');
|
| 684 |
+
const formatInfo = document.getElementById('format-info');
|
| 685 |
+
|
| 686 |
+
const formatDescriptions = {
|
| 687 |
+
'mp3': '🎵 MP3 - Good quality, small file size. Best for web and general use.',
|
| 688 |
+
'opus': '📻 OPUS - Excellent quality, small file size. Best for streaming and VoIP.',
|
| 689 |
+
'aac': '📱 AAC - Good quality, medium file size. Best for Apple devices and streaming.',
|
| 690 |
+
'flac': '💿 FLAC - Lossless quality, large file size. Best for archival and high-quality audio.',
|
| 691 |
+
'wav': '🎧 WAV - Lossless quality, large file size. Best for professional audio production.',
|
| 692 |
+
'pcm': '🔊 PCM - Raw audio data, large file size. Best for audio processing.'
|
| 693 |
+
};
|
| 694 |
+
|
| 695 |
+
if (formatInfo && formatSelect.value) {
|
| 696 |
+
formatInfo.textContent = formatDescriptions[formatSelect.value] || 'High-quality audio format';
|
| 697 |
+
}
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
function previewVoice(voiceId) {
|
| 701 |
+
// This would typically play a short preview of the voice
|
| 702 |
+
console.log(`Voice preview for ${voiceId} - Feature coming soon!`);
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
// Enhanced audio result display
|
| 706 |
+
function displayAudioResult(audioBlob, format, voice, text) {
|
| 707 |
+
const audioResult = document.getElementById('audio-result');
|
| 708 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 709 |
+
const audioInfo = document.getElementById('audio-info');
|
| 710 |
+
|
| 711 |
+
// Create audio URL and setup player
|
| 712 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
| 713 |
+
audioPlayer.src = audioUrl;
|
| 714 |
+
|
| 715 |
+
// Update audio stats
|
| 716 |
+
const sizeKB = (audioBlob.size / 1024).toFixed(1);
|
| 717 |
+
document.getElementById('audio-size').textContent = `${sizeKB} KB`;
|
| 718 |
+
document.getElementById('audio-format').textContent = format.toUpperCase();
|
| 719 |
+
document.getElementById('audio-voice').textContent = voice.charAt(0).toUpperCase() + voice.slice(1);
|
| 720 |
+
|
| 721 |
+
// Update audio info
|
| 722 |
+
audioInfo.innerHTML = `
|
| 723 |
+
<i class="fas fa-check-circle text-success me-1"></i>
|
| 724 |
+
Generated successfully • ${sizeKB} KB • ${format.toUpperCase()}
|
| 725 |
+
`;
|
| 726 |
+
|
| 727 |
+
// Show result with animation
|
| 728 |
+
audioResult.classList.remove('d-none');
|
| 729 |
+
audioResult.classList.add('fade-in');
|
| 730 |
+
|
| 731 |
+
// Update duration when metadata loads
|
| 732 |
+
audioPlayer.addEventListener('loadedmetadata', function() {
|
| 733 |
+
const duration = Math.round(audioPlayer.duration);
|
| 734 |
+
document.getElementById('audio-duration').textContent = `${duration}s`;
|
| 735 |
+
}, { once: true });
|
| 736 |
+
|
| 737 |
+
// Scroll to result
|
| 738 |
+
audioResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
// Export functions for use in HTML
|
| 742 |
+
window.enableAutoSplit = enableAutoSplit;
|
| 743 |
+
window.clearText = clearText;
|
| 744 |
+
window.loadRandomText = loadRandomText;
|
| 745 |
+
window.resetForm = resetForm;
|
ttsfm-web/templates/base.html
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}TTSFM - Text-to-Speech{% endblock %}</title>
|
| 7 |
+
|
| 8 |
+
<!-- Bootstrap CSS -->
|
| 9 |
+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 10 |
+
|
| 11 |
+
<!-- Font Awesome -->
|
| 12 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 13 |
+
|
| 14 |
+
<!-- Google Fonts -->
|
| 15 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 16 |
+
|
| 17 |
+
<!-- Custom CSS -->
|
| 18 |
+
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
| 19 |
+
|
| 20 |
+
<!-- Additional Performance Optimizations -->
|
| 21 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 22 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 23 |
+
|
| 24 |
+
<!-- Favicon -->
|
| 25 |
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎤</text></svg>">
|
| 26 |
+
|
| 27 |
+
<!-- Meta tags for better SEO and social sharing -->
|
| 28 |
+
<meta name="description" content="TTSFM - A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
|
| 29 |
+
<meta name="keywords" content="text-to-speech, TTS, python, API, voice synthesis, audio generation">
|
| 30 |
+
<meta name="author" content="TTSFM">
|
| 31 |
+
|
| 32 |
+
<!-- Open Graph / Facebook -->
|
| 33 |
+
<meta property="og:type" content="website">
|
| 34 |
+
<meta property="og:url" content="{{ request.url }}">
|
| 35 |
+
<meta property="og:title" content="{% block og_title %}TTSFM - Python Text-to-Speech Client{% endblock %}">
|
| 36 |
+
<meta property="og:description" content="A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
|
| 37 |
+
|
| 38 |
+
<!-- Twitter -->
|
| 39 |
+
<meta property="twitter:card" content="summary">
|
| 40 |
+
<meta property="twitter:url" content="{{ request.url }}">
|
| 41 |
+
<meta property="twitter:title" content="{% block twitter_title %}TTSFM - Python Text-to-Speech Client{% endblock %}">
|
| 42 |
+
<meta property="twitter:description" content="A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
|
| 43 |
+
|
| 44 |
+
{% block extra_css %}{% endblock %}
|
| 45 |
+
</head>
|
| 46 |
+
<body>
|
| 47 |
+
<!-- Skip to content link for accessibility -->
|
| 48 |
+
<a href="#main-content" class="skip-link">Skip to main content</a>
|
| 49 |
+
|
| 50 |
+
<!-- Clean Navigation -->
|
| 51 |
+
<nav class="navbar navbar-expand-lg fixed-top" style="background-color: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb;">
|
| 52 |
+
<div class="container">
|
| 53 |
+
<a class="navbar-brand" href="{{ url_for('index') }}">
|
| 54 |
+
<i class="fas fa-microphone-alt me-2"></i>
|
| 55 |
+
<span class="fw-bold">TTSFM</span>
|
| 56 |
+
<span class="badge bg-primary ms-2 small">v3.0</span>
|
| 57 |
+
</a>
|
| 58 |
+
|
| 59 |
+
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
| 60 |
+
<span class="navbar-toggler-icon"></span>
|
| 61 |
+
</button>
|
| 62 |
+
|
| 63 |
+
<div class="collapse navbar-collapse" id="navbarNav">
|
| 64 |
+
<ul class="navbar-nav me-auto">
|
| 65 |
+
<li class="nav-item">
|
| 66 |
+
<a class="nav-link" href="{{ url_for('index') }}" aria-label="Home page">
|
| 67 |
+
<i class="fas fa-home me-1"></i>Home
|
| 68 |
+
</a>
|
| 69 |
+
</li>
|
| 70 |
+
<li class="nav-item">
|
| 71 |
+
<a class="nav-link" href="{{ url_for('playground') }}" aria-label="Interactive playground">
|
| 72 |
+
<i class="fas fa-play me-1"></i>Playground
|
| 73 |
+
</a>
|
| 74 |
+
</li>
|
| 75 |
+
<li class="nav-item">
|
| 76 |
+
<a class="nav-link" href="{{ url_for('docs') }}" aria-label="API documentation">
|
| 77 |
+
<i class="fas fa-book me-1"></i>Documentation
|
| 78 |
+
</a>
|
| 79 |
+
</li>
|
| 80 |
+
</ul>
|
| 81 |
+
|
| 82 |
+
<ul class="navbar-nav">
|
| 83 |
+
<li class="nav-item">
|
| 84 |
+
<span class="navbar-text d-flex align-items-center">
|
| 85 |
+
<span id="status-indicator" class="status-indicator status-offline" aria-hidden="true"></span>
|
| 86 |
+
<span id="status-text" class="small">Checking...</span>
|
| 87 |
+
</span>
|
| 88 |
+
</li>
|
| 89 |
+
<li class="nav-item ms-2">
|
| 90 |
+
<a class="btn btn-outline-primary btn-sm" href="https://github.com/dbccccccc/ttsfm" target="_blank" rel="noopener noreferrer" aria-label="View source code on GitHub">
|
| 91 |
+
<i class="fab fa-github me-1"></i>GitHub
|
| 92 |
+
</a>
|
| 93 |
+
</li>
|
| 94 |
+
</ul>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</nav>
|
| 98 |
+
|
| 99 |
+
<!-- Main Content -->
|
| 100 |
+
<main id="main-content" style="padding-top: 76px;">
|
| 101 |
+
{% block content %}{% endblock %}
|
| 102 |
+
</main>
|
| 103 |
+
|
| 104 |
+
<!-- Simplified Footer -->
|
| 105 |
+
<footer class="footer py-4" style="background-color: #f8fafc; border-top: 1px solid #e5e7eb;" role="contentinfo">
|
| 106 |
+
<div class="container">
|
| 107 |
+
<div class="row align-items-center">
|
| 108 |
+
<div class="col-md-6">
|
| 109 |
+
<div class="d-flex align-items-center mb-2 mb-md-0">
|
| 110 |
+
<i class="fas fa-microphone-alt me-2 text-primary"></i>
|
| 111 |
+
<strong class="text-dark">TTSFM</strong>
|
| 112 |
+
<span class="ms-2 text-muted">Free Text-to-Speech for Python</span>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="col-md-6 text-md-end">
|
| 116 |
+
<div class="d-flex justify-content-md-end gap-3">
|
| 117 |
+
<a href="{{ url_for('playground') }}" class="text-decoration-none" style="color: #6b7280;">
|
| 118 |
+
<i class="fas fa-play me-1"></i>Demo
|
| 119 |
+
</a>
|
| 120 |
+
<a href="{{ url_for('docs') }}" class="text-decoration-none" style="color: #6b7280;">
|
| 121 |
+
<i class="fas fa-book me-1"></i>Docs
|
| 122 |
+
</a>
|
| 123 |
+
<a href="https://github.com/dbccccccc/ttsfm" class="text-decoration-none" style="color: #6b7280;" target="_blank" rel="noopener noreferrer">
|
| 124 |
+
<i class="fab fa-github me-1"></i>GitHub
|
| 125 |
+
</a>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
<hr class="my-3" style="border-color: #e5e7eb;">
|
| 130 |
+
<div class="row align-items-center">
|
| 131 |
+
<div class="col-md-6">
|
| 132 |
+
<small class="text-muted">© 2024 TTSFM. MIT License.</small>
|
| 133 |
+
</div>
|
| 134 |
+
<div class="col-md-6 text-md-end">
|
| 135 |
+
<small class="text-muted">
|
| 136 |
+
<span id="footer-status" class="d-inline-flex align-items-center">
|
| 137 |
+
<span class="status-indicator status-offline me-2"></span>
|
| 138 |
+
Status: <span id="footer-status-text" class="ms-1">Checking...</span>
|
| 139 |
+
</span>
|
| 140 |
+
</small>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</footer>
|
| 145 |
+
|
| 146 |
+
<!-- Bootstrap JS -->
|
| 147 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
| 148 |
+
|
| 149 |
+
<!-- Enhanced Common JavaScript -->
|
| 150 |
+
<script>
|
| 151 |
+
// Enhanced service status checking
|
| 152 |
+
async function checkStatus() {
|
| 153 |
+
try {
|
| 154 |
+
const response = await fetch('/api/health');
|
| 155 |
+
const data = await response.json();
|
| 156 |
+
|
| 157 |
+
const indicator = document.getElementById('status-indicator');
|
| 158 |
+
const text = document.getElementById('status-text');
|
| 159 |
+
const footerIndicator = document.querySelector('#footer-status .status-indicator');
|
| 160 |
+
const footerText = document.getElementById('footer-status-text');
|
| 161 |
+
|
| 162 |
+
if (response.ok && data.status === 'healthy') {
|
| 163 |
+
// Update navbar status
|
| 164 |
+
indicator.className = 'status-indicator status-online';
|
| 165 |
+
text.textContent = 'Online';
|
| 166 |
+
|
| 167 |
+
// Update footer status
|
| 168 |
+
if (footerIndicator) footerIndicator.className = 'status-indicator status-online';
|
| 169 |
+
if (footerText) footerText.textContent = 'Online';
|
| 170 |
+
} else {
|
| 171 |
+
// Update navbar status
|
| 172 |
+
indicator.className = 'status-indicator status-offline';
|
| 173 |
+
text.textContent = 'Offline';
|
| 174 |
+
|
| 175 |
+
// Update footer status
|
| 176 |
+
if (footerIndicator) footerIndicator.className = 'status-indicator status-offline';
|
| 177 |
+
if (footerText) footerText.textContent = 'Offline';
|
| 178 |
+
}
|
| 179 |
+
} catch (error) {
|
| 180 |
+
// Update navbar status
|
| 181 |
+
const indicator = document.getElementById('status-indicator');
|
| 182 |
+
const text = document.getElementById('status-text');
|
| 183 |
+
indicator.className = 'status-indicator status-offline';
|
| 184 |
+
text.textContent = 'Offline';
|
| 185 |
+
|
| 186 |
+
// Update footer status
|
| 187 |
+
const footerIndicator = document.querySelector('#footer-status .status-indicator');
|
| 188 |
+
const footerText = document.getElementById('footer-status-text');
|
| 189 |
+
if (footerIndicator) footerIndicator.className = 'status-indicator status-offline';
|
| 190 |
+
if (footerText) footerText.textContent = 'Offline';
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// Enhanced page initialization
|
| 195 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 196 |
+
// Check status immediately and periodically
|
| 197 |
+
checkStatus();
|
| 198 |
+
setInterval(checkStatus, 30000); // Check every 30 seconds
|
| 199 |
+
|
| 200 |
+
// Initialize tooltips
|
| 201 |
+
if (typeof bootstrap !== 'undefined') {
|
| 202 |
+
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
| 203 |
+
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
| 204 |
+
return new bootstrap.Tooltip(tooltipTriggerEl);
|
| 205 |
+
});
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Add smooth scrolling for anchor links
|
| 209 |
+
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
| 210 |
+
anchor.addEventListener('click', function (e) {
|
| 211 |
+
const target = document.querySelector(this.getAttribute('href'));
|
| 212 |
+
if (target) {
|
| 213 |
+
e.preventDefault();
|
| 214 |
+
target.scrollIntoView({
|
| 215 |
+
behavior: 'smooth',
|
| 216 |
+
block: 'start'
|
| 217 |
+
});
|
| 218 |
+
}
|
| 219 |
+
});
|
| 220 |
+
});
|
| 221 |
+
|
| 222 |
+
// Add fade-in animation to main content
|
| 223 |
+
const mainContent = document.querySelector('main');
|
| 224 |
+
if (mainContent) {
|
| 225 |
+
mainContent.classList.add('fade-in');
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// Add loading states to external links
|
| 229 |
+
document.querySelectorAll('a[target="_blank"]').forEach(link => {
|
| 230 |
+
link.addEventListener('click', function() {
|
| 231 |
+
this.style.opacity = '0.7';
|
| 232 |
+
setTimeout(() => {
|
| 233 |
+
this.style.opacity = '1';
|
| 234 |
+
}, 1000);
|
| 235 |
+
});
|
| 236 |
+
});
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
// Enhanced utility function to show loading state
|
| 240 |
+
function setLoading(button, loading) {
|
| 241 |
+
if (loading) {
|
| 242 |
+
button.classList.add('loading');
|
| 243 |
+
button.disabled = true;
|
| 244 |
+
button.style.cursor = 'wait';
|
| 245 |
+
} else {
|
| 246 |
+
button.classList.remove('loading');
|
| 247 |
+
button.disabled = false;
|
| 248 |
+
button.style.cursor = 'pointer';
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// Enhanced utility function to show alerts
|
| 253 |
+
function showAlert(message, type = 'info', duration = 5000) {
|
| 254 |
+
const alertDiv = document.createElement('div');
|
| 255 |
+
alertDiv.className = `alert alert-${type} alert-dismissible fade show fade-in`;
|
| 256 |
+
alertDiv.style.position = 'relative';
|
| 257 |
+
alertDiv.style.zIndex = '1050';
|
| 258 |
+
alertDiv.innerHTML = `
|
| 259 |
+
<i class="fas fa-${getAlertIcon(type)} me-2"></i>
|
| 260 |
+
${message}
|
| 261 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 262 |
+
`;
|
| 263 |
+
|
| 264 |
+
// Find the best container to insert the alert
|
| 265 |
+
const container = document.querySelector('main .container') || document.querySelector('.container') || document.body;
|
| 266 |
+
if (container) {
|
| 267 |
+
container.insertBefore(alertDiv, container.firstChild);
|
| 268 |
+
|
| 269 |
+
// Auto-dismiss after specified duration
|
| 270 |
+
setTimeout(() => {
|
| 271 |
+
if (alertDiv.parentNode) {
|
| 272 |
+
alertDiv.classList.remove('show');
|
| 273 |
+
setTimeout(() => {
|
| 274 |
+
if (alertDiv.parentNode) {
|
| 275 |
+
alertDiv.remove();
|
| 276 |
+
}
|
| 277 |
+
}, 150);
|
| 278 |
+
}
|
| 279 |
+
}, duration);
|
| 280 |
+
|
| 281 |
+
// Scroll to alert if it's not visible
|
| 282 |
+
alertDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// Helper function to get appropriate icon for alert type
|
| 287 |
+
function getAlertIcon(type) {
|
| 288 |
+
const icons = {
|
| 289 |
+
'success': 'check-circle',
|
| 290 |
+
'danger': 'exclamation-triangle',
|
| 291 |
+
'warning': 'exclamation-triangle',
|
| 292 |
+
'info': 'info-circle',
|
| 293 |
+
'primary': 'info-circle'
|
| 294 |
+
};
|
| 295 |
+
return icons[type] || 'info-circle';
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// Enhanced error handling for fetch requests
|
| 299 |
+
async function safeFetch(url, options = {}) {
|
| 300 |
+
try {
|
| 301 |
+
const response = await fetch(url, options);
|
| 302 |
+
if (!response.ok) {
|
| 303 |
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| 304 |
+
}
|
| 305 |
+
return response;
|
| 306 |
+
} catch (error) {
|
| 307 |
+
console.error('Fetch error:', error);
|
| 308 |
+
showAlert(`Network error: ${error.message}`, 'danger');
|
| 309 |
+
throw error;
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// Performance monitoring
|
| 314 |
+
window.addEventListener('load', function() {
|
| 315 |
+
// Log page load time
|
| 316 |
+
const loadTime = performance.now();
|
| 317 |
+
console.log(`Page loaded in ${Math.round(loadTime)}ms`);
|
| 318 |
+
|
| 319 |
+
// Check for slow loading resources
|
| 320 |
+
if (loadTime > 3000) {
|
| 321 |
+
console.warn('Page load time is slow. Consider optimizing resources.');
|
| 322 |
+
}
|
| 323 |
+
});
|
| 324 |
+
|
| 325 |
+
// Keyboard shortcuts
|
| 326 |
+
document.addEventListener('keydown', function(e) {
|
| 327 |
+
// Alt + H for home
|
| 328 |
+
if (e.altKey && e.key === 'h') {
|
| 329 |
+
e.preventDefault();
|
| 330 |
+
window.location.href = '{{ url_for("index") }}';
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// Alt + P for playground
|
| 334 |
+
if (e.altKey && e.key === 'p') {
|
| 335 |
+
e.preventDefault();
|
| 336 |
+
window.location.href = '{{ url_for("playground") }}';
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
// Alt + D for docs
|
| 340 |
+
if (e.altKey && e.key === 'd') {
|
| 341 |
+
e.preventDefault();
|
| 342 |
+
window.location.href = '{{ url_for("docs") }}';
|
| 343 |
+
}
|
| 344 |
+
});
|
| 345 |
+
</script>
|
| 346 |
+
|
| 347 |
+
{% block extra_js %}{% endblock %}
|
| 348 |
+
</body>
|
| 349 |
+
</html>
|
ttsfm-web/templates/docs.html
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}TTSFM API Documentation{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block extra_css %}
|
| 6 |
+
<style>
|
| 7 |
+
.code-block {
|
| 8 |
+
background-color: #f8f9fa;
|
| 9 |
+
border: 1px solid #e9ecef;
|
| 10 |
+
border-radius: 0.375rem;
|
| 11 |
+
padding: 1rem;
|
| 12 |
+
margin: 1rem 0;
|
| 13 |
+
overflow-x: auto;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.endpoint-card {
|
| 17 |
+
border-left: 4px solid #007bff;
|
| 18 |
+
margin-bottom: 2rem;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.method-badge {
|
| 22 |
+
font-size: 0.75rem;
|
| 23 |
+
padding: 0.25rem 0.5rem;
|
| 24 |
+
border-radius: 0.25rem;
|
| 25 |
+
font-weight: bold;
|
| 26 |
+
margin-right: 0.5rem;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.method-get { background-color: #28a745; color: white; }
|
| 30 |
+
.method-post { background-color: #007bff; color: white; }
|
| 31 |
+
.method-put { background-color: #ffc107; color: black; }
|
| 32 |
+
.method-delete { background-color: #dc3545; color: white; }
|
| 33 |
+
|
| 34 |
+
.response-example {
|
| 35 |
+
background-color: #f1f3f4;
|
| 36 |
+
border-radius: 0.375rem;
|
| 37 |
+
padding: 1rem;
|
| 38 |
+
margin-top: 1rem;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.toc {
|
| 42 |
+
position: sticky;
|
| 43 |
+
top: 2rem;
|
| 44 |
+
max-height: calc(100vh - 4rem);
|
| 45 |
+
overflow-y: auto;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.toc a {
|
| 49 |
+
color: #6c757d;
|
| 50 |
+
text-decoration: none;
|
| 51 |
+
display: block;
|
| 52 |
+
padding: 0.25rem 0;
|
| 53 |
+
border-left: 2px solid transparent;
|
| 54 |
+
padding-left: 1rem;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.toc a:hover, .toc a.active {
|
| 58 |
+
color: #007bff;
|
| 59 |
+
border-left-color: #007bff;
|
| 60 |
+
}
|
| 61 |
+
</style>
|
| 62 |
+
{% endblock %}
|
| 63 |
+
|
| 64 |
+
{% block content %}
|
| 65 |
+
<div class="container py-5">
|
| 66 |
+
<div class="row">
|
| 67 |
+
<div class="col-12 text-center mb-5">
|
| 68 |
+
<h1 class="display-4 fw-bold">
|
| 69 |
+
<i class="fas fa-book me-3"></i>API Documentation
|
| 70 |
+
</h1>
|
| 71 |
+
<p class="lead text-muted">
|
| 72 |
+
Complete reference for the TTSFM Text-to-Speech API
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div class="row">
|
| 78 |
+
<!-- Table of Contents -->
|
| 79 |
+
<div class="col-lg-3">
|
| 80 |
+
<div class="toc">
|
| 81 |
+
<h5 class="fw-bold mb-3">Contents</h5>
|
| 82 |
+
<a href="#overview">Overview</a>
|
| 83 |
+
<a href="#authentication">Authentication</a>
|
| 84 |
+
<a href="#text-validation">Text Validation</a>
|
| 85 |
+
<a href="#endpoints">API Endpoints</a>
|
| 86 |
+
<a href="#voices">Voices</a>
|
| 87 |
+
<a href="#formats">Audio Formats</a>
|
| 88 |
+
<a href="#generate">Generate Speech</a>
|
| 89 |
+
<a href="#batch">Batch Processing</a>
|
| 90 |
+
<a href="#status">Status & Health</a>
|
| 91 |
+
<a href="#errors">Error Handling</a>
|
| 92 |
+
<a href="#examples">Code Examples</a>
|
| 93 |
+
<a href="#python-package">Python Package</a>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<!-- Documentation Content -->
|
| 98 |
+
<div class="col-lg-9">
|
| 99 |
+
<!-- Overview -->
|
| 100 |
+
<section id="overview" class="mb-5">
|
| 101 |
+
<h2 class="fw-bold mb-3">Overview</h2>
|
| 102 |
+
<p>
|
| 103 |
+
The TTSFM API provides a modern, OpenAI-compatible interface for text-to-speech generation.
|
| 104 |
+
It supports multiple voices, audio formats, and includes advanced features like text length
|
| 105 |
+
validation and batch processing.
|
| 106 |
+
</p>
|
| 107 |
+
|
| 108 |
+
<div class="alert alert-info">
|
| 109 |
+
<i class="fas fa-info-circle me-2"></i>
|
| 110 |
+
<strong>Base URL:</strong> <code>{{ request.url_root }}api/</code>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<h4>Key Features</h4>
|
| 114 |
+
<ul>
|
| 115 |
+
<li>11 different voice options</li>
|
| 116 |
+
<li>Multiple audio formats (MP3, WAV, OPUS, etc.)</li>
|
| 117 |
+
<li>Text length validation (4096 character limit)</li>
|
| 118 |
+
<li>Automatic text splitting for long content</li>
|
| 119 |
+
<li>Batch processing capabilities</li>
|
| 120 |
+
<li>Real-time status monitoring</li>
|
| 121 |
+
</ul>
|
| 122 |
+
</section>
|
| 123 |
+
|
| 124 |
+
<!-- Authentication -->
|
| 125 |
+
<section id="authentication" class="mb-5">
|
| 126 |
+
<h2 class="fw-bold mb-3">Authentication</h2>
|
| 127 |
+
<p>
|
| 128 |
+
Currently, the API supports optional API key authentication. If configured,
|
| 129 |
+
include your API key in the request headers.
|
| 130 |
+
</p>
|
| 131 |
+
|
| 132 |
+
<div class="code-block">
|
| 133 |
+
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
| 134 |
+
</div>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
<!-- Text Validation -->
|
| 138 |
+
<section id="text-validation" class="mb-5">
|
| 139 |
+
<h2 class="fw-bold mb-3">Text Length Validation</h2>
|
| 140 |
+
<p>
|
| 141 |
+
TTSFM includes built-in text length validation to ensure compatibility with TTS models.
|
| 142 |
+
The default maximum length is 4096 characters, but this can be customized.
|
| 143 |
+
</p>
|
| 144 |
+
|
| 145 |
+
<div class="alert alert-warning">
|
| 146 |
+
<i class="fas fa-exclamation-triangle me-2"></i>
|
| 147 |
+
<strong>Important:</strong> Text exceeding the maximum length will be rejected unless
|
| 148 |
+
validation is disabled or the text is split into chunks.
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
<h4>Validation Options</h4>
|
| 152 |
+
<ul>
|
| 153 |
+
<li><code>max_length</code>: Maximum allowed characters (default: 4096)</li>
|
| 154 |
+
<li><code>validate_length</code>: Enable/disable validation (default: true)</li>
|
| 155 |
+
<li><code>preserve_words</code>: Avoid splitting words when chunking (default: true)</li>
|
| 156 |
+
</ul>
|
| 157 |
+
</section>
|
| 158 |
+
|
| 159 |
+
<!-- API Endpoints -->
|
| 160 |
+
<section id="endpoints" class="mb-5">
|
| 161 |
+
<h2 class="fw-bold mb-3">API Endpoints</h2>
|
| 162 |
+
|
| 163 |
+
<!-- Voices Endpoint -->
|
| 164 |
+
<div class="card endpoint-card" id="voices">
|
| 165 |
+
<div class="card-body">
|
| 166 |
+
<h4 class="card-title">
|
| 167 |
+
<span class="method-badge method-get">GET</span>
|
| 168 |
+
/api/voices
|
| 169 |
+
</h4>
|
| 170 |
+
<p class="card-text">Get list of available voices.</p>
|
| 171 |
+
|
| 172 |
+
<h6>Response Example:</h6>
|
| 173 |
+
<div class="response-example">
|
| 174 |
+
<pre><code>{
|
| 175 |
+
"voices": [
|
| 176 |
+
{
|
| 177 |
+
"id": "alloy",
|
| 178 |
+
"name": "Alloy",
|
| 179 |
+
"description": "Alloy voice"
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"id": "echo",
|
| 183 |
+
"name": "Echo",
|
| 184 |
+
"description": "Echo voice"
|
| 185 |
+
}
|
| 186 |
+
],
|
| 187 |
+
"count": 6
|
| 188 |
+
}</code></pre>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<!-- Formats Endpoint -->
|
| 194 |
+
<div class="card endpoint-card" id="formats">
|
| 195 |
+
<div class="card-body">
|
| 196 |
+
<h4 class="card-title">
|
| 197 |
+
<span class="method-badge method-get">GET</span>
|
| 198 |
+
/api/formats
|
| 199 |
+
</h4>
|
| 200 |
+
<p class="card-text">Get list of supported audio formats.</p>
|
| 201 |
+
|
| 202 |
+
<h6>Response Example:</h6>
|
| 203 |
+
<div class="response-example">
|
| 204 |
+
<pre><code>{
|
| 205 |
+
"formats": [
|
| 206 |
+
{
|
| 207 |
+
"id": "mp3",
|
| 208 |
+
"name": "MP3",
|
| 209 |
+
"mime_type": "audio/mp3",
|
| 210 |
+
"description": "MP3 audio format"
|
| 211 |
+
}
|
| 212 |
+
],
|
| 213 |
+
"count": 6
|
| 214 |
+
}</code></pre>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<!-- Text Validation Endpoint -->
|
| 220 |
+
<div class="card endpoint-card">
|
| 221 |
+
<div class="card-body">
|
| 222 |
+
<h4 class="card-title">
|
| 223 |
+
<span class="method-badge method-post">POST</span>
|
| 224 |
+
/api/validate-text
|
| 225 |
+
</h4>
|
| 226 |
+
<p class="card-text">Validate text length and get splitting suggestions.</p>
|
| 227 |
+
|
| 228 |
+
<h6>Request Body:</h6>
|
| 229 |
+
<div class="code-block">
|
| 230 |
+
<pre><code>{
|
| 231 |
+
"text": "Your text to validate",
|
| 232 |
+
"max_length": 4096
|
| 233 |
+
}</code></pre>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<h6>Response Example:</h6>
|
| 237 |
+
<div class="response-example">
|
| 238 |
+
<pre><code>{
|
| 239 |
+
"text_length": 5000,
|
| 240 |
+
"max_length": 4096,
|
| 241 |
+
"is_valid": false,
|
| 242 |
+
"needs_splitting": true,
|
| 243 |
+
"suggested_chunks": 2,
|
| 244 |
+
"chunk_preview": [
|
| 245 |
+
"First chunk preview...",
|
| 246 |
+
"Second chunk preview..."
|
| 247 |
+
]
|
| 248 |
+
}</code></pre>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<!-- Generate Speech Endpoint -->
|
| 254 |
+
<div class="card endpoint-card" id="generate">
|
| 255 |
+
<div class="card-body">
|
| 256 |
+
<h4 class="card-title">
|
| 257 |
+
<span class="method-badge method-post">POST</span>
|
| 258 |
+
/api/generate
|
| 259 |
+
</h4>
|
| 260 |
+
<p class="card-text">Generate speech from text.</p>
|
| 261 |
+
|
| 262 |
+
<h6>Request Body:</h6>
|
| 263 |
+
<div class="code-block">
|
| 264 |
+
<pre><code>{
|
| 265 |
+
"text": "Hello, world!",
|
| 266 |
+
"voice": "alloy",
|
| 267 |
+
"format": "mp3",
|
| 268 |
+
"instructions": "Speak cheerfully",
|
| 269 |
+
"max_length": 4096,
|
| 270 |
+
"validate_length": true
|
| 271 |
+
}</code></pre>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<h6>Parameters:</h6>
|
| 275 |
+
<ul>
|
| 276 |
+
<li><code>text</code> (required): Text to convert to speech</li>
|
| 277 |
+
<li><code>voice</code> (optional): Voice ID (default: "alloy")</li>
|
| 278 |
+
<li><code>format</code> (optional): Audio format (default: "mp3")</li>
|
| 279 |
+
<li><code>instructions</code> (optional): Voice modulation instructions</li>
|
| 280 |
+
<li><code>max_length</code> (optional): Maximum text length (default: 4096)</li>
|
| 281 |
+
<li><code>validate_length</code> (optional): Enable validation (default: true)</li>
|
| 282 |
+
</ul>
|
| 283 |
+
|
| 284 |
+
<h6>Response:</h6>
|
| 285 |
+
<p>Returns audio file with appropriate Content-Type header.</p>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<!-- Batch Processing Endpoint -->
|
| 290 |
+
<div class="card endpoint-card" id="batch">
|
| 291 |
+
<div class="card-body">
|
| 292 |
+
<h4 class="card-title">
|
| 293 |
+
<span class="method-badge method-post">POST</span>
|
| 294 |
+
/api/generate-batch
|
| 295 |
+
</h4>
|
| 296 |
+
<p class="card-text">Generate speech from long text by automatically splitting into chunks.</p>
|
| 297 |
+
|
| 298 |
+
<h6>Request Body:</h6>
|
| 299 |
+
<div class="code-block">
|
| 300 |
+
<pre><code>{
|
| 301 |
+
"text": "Very long text that exceeds the limit...",
|
| 302 |
+
"voice": "alloy",
|
| 303 |
+
"format": "mp3",
|
| 304 |
+
"max_length": 4096,
|
| 305 |
+
"preserve_words": true
|
| 306 |
+
}</code></pre>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<h6>Response Example:</h6>
|
| 310 |
+
<div class="response-example">
|
| 311 |
+
<pre><code>{
|
| 312 |
+
"total_chunks": 3,
|
| 313 |
+
"successful_chunks": 3,
|
| 314 |
+
"results": [
|
| 315 |
+
{
|
| 316 |
+
"chunk_index": 1,
|
| 317 |
+
"chunk_text": "First chunk text...",
|
| 318 |
+
"audio_data": "base64_encoded_audio",
|
| 319 |
+
"content_type": "audio/mp3",
|
| 320 |
+
"size": 12345,
|
| 321 |
+
"format": "mp3"
|
| 322 |
+
}
|
| 323 |
+
]
|
| 324 |
+
}</code></pre>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
</section>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
{% endblock %}
|
| 333 |
+
|
| 334 |
+
{% block extra_js %}
|
| 335 |
+
<script>
|
| 336 |
+
// Smooth scrolling for TOC links
|
| 337 |
+
document.querySelectorAll('.toc a').forEach(link => {
|
| 338 |
+
link.addEventListener('click', function(e) {
|
| 339 |
+
e.preventDefault();
|
| 340 |
+
const target = document.querySelector(this.getAttribute('href'));
|
| 341 |
+
if (target) {
|
| 342 |
+
target.scrollIntoView({ behavior: 'smooth' });
|
| 343 |
+
|
| 344 |
+
// Update active link
|
| 345 |
+
document.querySelectorAll('.toc a').forEach(l => l.classList.remove('active'));
|
| 346 |
+
this.classList.add('active');
|
| 347 |
+
}
|
| 348 |
+
});
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
// Highlight current section in TOC
|
| 352 |
+
window.addEventListener('scroll', function() {
|
| 353 |
+
const sections = document.querySelectorAll('section[id]');
|
| 354 |
+
const scrollPos = window.scrollY + 100;
|
| 355 |
+
|
| 356 |
+
sections.forEach(section => {
|
| 357 |
+
const top = section.offsetTop;
|
| 358 |
+
const bottom = top + section.offsetHeight;
|
| 359 |
+
const id = section.getAttribute('id');
|
| 360 |
+
const link = document.querySelector(`.toc a[href="#${id}"]`);
|
| 361 |
+
|
| 362 |
+
if (scrollPos >= top && scrollPos < bottom) {
|
| 363 |
+
document.querySelectorAll('.toc a').forEach(l => l.classList.remove('active'));
|
| 364 |
+
if (link) link.classList.add('active');
|
| 365 |
+
}
|
| 366 |
+
});
|
| 367 |
+
});
|
| 368 |
+
</script>
|
| 369 |
+
{% endblock %}
|
ttsfm-web/templates/index.html
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}TTSFM - Free Text-to-Speech for Python{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<!-- Hero Section -->
|
| 7 |
+
<section class="hero-section">
|
| 8 |
+
<div class="container">
|
| 9 |
+
<div class="row align-items-center min-vh-75">
|
| 10 |
+
<div class="col-lg-8 mx-auto text-center">
|
| 11 |
+
<div class="hero-content">
|
| 12 |
+
<div class="badge bg-primary text-white mb-3 px-3 py-2">
|
| 13 |
+
<i class="fas fa-code me-2"></i>Python Package
|
| 14 |
+
</div>
|
| 15 |
+
<h1 class="display-4 fw-bold mb-4">
|
| 16 |
+
Free Text-to-Speech for Python
|
| 17 |
+
</h1>
|
| 18 |
+
<p class="lead mb-4">
|
| 19 |
+
Access free text-to-speech using openai.fm's service. No API keys required,
|
| 20 |
+
just install and use immediately.
|
| 21 |
+
</p>
|
| 22 |
+
<div class="d-flex flex-wrap gap-3 justify-content-center">
|
| 23 |
+
<a href="{{ url_for('playground') }}" class="btn btn-primary btn-lg">
|
| 24 |
+
<i class="fas fa-play me-2"></i>Try Demo
|
| 25 |
+
</a>
|
| 26 |
+
<a href="{{ url_for('docs') }}" class="btn btn-outline-secondary btn-lg">
|
| 27 |
+
<i class="fas fa-book me-2"></i>Documentation
|
| 28 |
+
</a>
|
| 29 |
+
<a href="https://github.com/dbccccccc/ttsfm" class="btn btn-outline-secondary btn-lg" target="_blank" rel="noopener noreferrer">
|
| 30 |
+
<i class="fab fa-github me-2"></i>GitHub
|
| 31 |
+
</a>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
</section>
|
| 38 |
+
|
| 39 |
+
<!-- Features Section -->
|
| 40 |
+
<section class="py-5" style="background-color: #f8fafc;">
|
| 41 |
+
<div class="container">
|
| 42 |
+
<div class="row">
|
| 43 |
+
<div class="col-12 text-center mb-5">
|
| 44 |
+
<h2 class="fw-bold mb-4">Key Features</h2>
|
| 45 |
+
<p class="lead text-muted">
|
| 46 |
+
Simple, free, and powerful text-to-speech for Python developers.
|
| 47 |
+
</p>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="row g-4">
|
| 52 |
+
<div class="col-lg-4">
|
| 53 |
+
<div class="text-center">
|
| 54 |
+
<div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background-color: #2563eb;">
|
| 55 |
+
<i class="fas fa-key"></i>
|
| 56 |
+
</div>
|
| 57 |
+
<h5 class="fw-bold">No API Keys</h5>
|
| 58 |
+
<p class="text-muted">Completely free service with no registration or API keys required.</p>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="col-lg-4">
|
| 63 |
+
<div class="text-center">
|
| 64 |
+
<div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background-color: #10b981;">
|
| 65 |
+
<i class="fas fa-bolt"></i>
|
| 66 |
+
</div>
|
| 67 |
+
<h5 class="fw-bold">Easy to Use</h5>
|
| 68 |
+
<p class="text-muted">Simple Python API with both sync and async support for all use cases.</p>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div class="col-lg-4">
|
| 73 |
+
<div class="text-center">
|
| 74 |
+
<div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background-color: #64748b;">
|
| 75 |
+
<i class="fas fa-microphone-alt"></i>
|
| 76 |
+
</div>
|
| 77 |
+
<h5 class="fw-bold">Multiple Voices</h5>
|
| 78 |
+
<p class="text-muted">Access to various voice options and audio formats for your needs.</p>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</section>
|
| 84 |
+
|
| 85 |
+
<!-- Quick Start Section -->
|
| 86 |
+
<section class="py-5">
|
| 87 |
+
<div class="container">
|
| 88 |
+
<div class="row">
|
| 89 |
+
<div class="col-12 text-center mb-5">
|
| 90 |
+
<h2 class="fw-bold mb-4">Getting Started</h2>
|
| 91 |
+
<p class="lead text-muted">
|
| 92 |
+
Install TTSFM and start generating speech with just a few lines of code.
|
| 93 |
+
</p>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div class="row g-4">
|
| 98 |
+
<div class="col-lg-6">
|
| 99 |
+
<div class="card h-100">
|
| 100 |
+
<div class="card-body">
|
| 101 |
+
<h5 class="card-title">
|
| 102 |
+
<i class="fas fa-download me-2 text-primary"></i>Installation
|
| 103 |
+
</h5>
|
| 104 |
+
<pre class="bg-light p-3 rounded"><code>pip install ttsfm</code></pre>
|
| 105 |
+
<small class="text-muted">Requires Python 3.8+</small>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div class="col-lg-6">
|
| 111 |
+
<div class="card h-100">
|
| 112 |
+
<div class="card-body">
|
| 113 |
+
<h5 class="card-title">
|
| 114 |
+
<i class="fas fa-play me-2 text-success"></i>Basic Usage
|
| 115 |
+
</h5>
|
| 116 |
+
<pre class="bg-light p-3 rounded"><code>from ttsfm import TTSClient
|
| 117 |
+
|
| 118 |
+
client = TTSClient()
|
| 119 |
+
response = client.generate_speech(
|
| 120 |
+
text="Hello, world!",
|
| 121 |
+
voice="alloy"
|
| 122 |
+
)
|
| 123 |
+
response.save_to_file("hello.wav")</code></pre>
|
| 124 |
+
<small class="text-muted">No API keys required</small>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<div class="row mt-4">
|
| 131 |
+
<div class="col-12 text-center">
|
| 132 |
+
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
| 133 |
+
<a href="{{ url_for('playground') }}" class="btn btn-primary">
|
| 134 |
+
<i class="fas fa-play me-2"></i>Try Demo
|
| 135 |
+
</a>
|
| 136 |
+
<a href="{{ url_for('docs') }}" class="btn btn-outline-primary">
|
| 137 |
+
<i class="fas fa-book me-2"></i>Documentation
|
| 138 |
+
</a>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</section>
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
{% endblock %}
|
ttsfm-web/templates/playground.html
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}TTSFM Playground - Try Text-to-Speech{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<!-- Clean Playground Header -->
|
| 7 |
+
<section class="py-5" style="background-color: white; border-bottom: 1px solid #e5e7eb;">
|
| 8 |
+
<div class="container">
|
| 9 |
+
<div class="row align-items-center">
|
| 10 |
+
<div class="col-lg-8">
|
| 11 |
+
<div class="fade-in">
|
| 12 |
+
<div class="badge bg-primary text-white mb-3 px-3 py-2">
|
| 13 |
+
<i class="fas fa-flask me-2"></i>Demo
|
| 14 |
+
</div>
|
| 15 |
+
<h1 class="display-4 fw-bold mb-3 text-dark">
|
| 16 |
+
<i class="fas fa-play-circle me-3 text-primary"></i>TTS Playground
|
| 17 |
+
</h1>
|
| 18 |
+
<p class="lead mb-4 text-muted">
|
| 19 |
+
Test the TTSFM text-to-speech functionality with different voices and formats.
|
| 20 |
+
</p>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="col-lg-4 text-center">
|
| 24 |
+
<div class="playground-visual fade-in" style="animation-delay: 0.3s;">
|
| 25 |
+
<div class="playground-icon">
|
| 26 |
+
<i class="fas fa-waveform-lines text-primary"></i>
|
| 27 |
+
<div class="pulse-ring"></div>
|
| 28 |
+
<div class="pulse-ring pulse-ring-delay"></div>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<div class="container py-5 playground">
|
| 37 |
+
|
| 38 |
+
<div class="row">
|
| 39 |
+
<div class="col-lg-10 mx-auto">
|
| 40 |
+
<div class="card shadow-lg-custom border-0 fade-in">
|
| 41 |
+
<div class="card-header bg-gradient-primary text-white">
|
| 42 |
+
<h4 class="mb-0 d-flex align-items-center">
|
| 43 |
+
<i class="fas fa-microphone me-2"></i>
|
| 44 |
+
Text-to-Speech Generator
|
| 45 |
+
</h4>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="card-body p-4">
|
| 48 |
+
<form id="tts-form">
|
| 49 |
+
<!-- Enhanced Text Input -->
|
| 50 |
+
<div class="mb-4">
|
| 51 |
+
<label for="text-input" class="form-label fw-bold d-flex align-items-center">
|
| 52 |
+
<i class="fas fa-edit me-2 text-primary"></i>
|
| 53 |
+
Text to Convert
|
| 54 |
+
</label>
|
| 55 |
+
<div class="position-relative">
|
| 56 |
+
<textarea
|
| 57 |
+
class="form-control shadow-sm"
|
| 58 |
+
id="text-input"
|
| 59 |
+
rows="4"
|
| 60 |
+
placeholder="Enter the text you want to convert to speech..."
|
| 61 |
+
required
|
| 62 |
+
>Hello! This is a test of the TTSFM text-to-speech system.</textarea>
|
| 63 |
+
<div class="position-absolute top-0 end-0 p-2">
|
| 64 |
+
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-text-btn" title="Clear text">
|
| 65 |
+
<i class="fas fa-times"></i>
|
| 66 |
+
</button>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="form-text d-flex justify-content-between align-items-center">
|
| 70 |
+
<div class="d-flex align-items-center gap-3">
|
| 71 |
+
<span class="text-muted">
|
| 72 |
+
<i class="fas fa-keyboard me-1"></i>
|
| 73 |
+
<span id="char-count">0</span> characters
|
| 74 |
+
</span>
|
| 75 |
+
<span id="length-status" class=""></span>
|
| 76 |
+
<span class="text-muted small">
|
| 77 |
+
<i class="fas fa-lightbulb me-1"></i>
|
| 78 |
+
Tip: Use Ctrl+Enter to generate
|
| 79 |
+
</span>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="btn-group" role="group">
|
| 82 |
+
<button type="button" class="btn btn-sm btn-outline-primary" id="validate-text-btn">
|
| 83 |
+
<i class="fas fa-check me-1"></i>Validate
|
| 84 |
+
</button>
|
| 85 |
+
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-text-btn">
|
| 86 |
+
<i class="fas fa-dice me-1"></i>Random
|
| 87 |
+
</button>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div id="validation-result" class="mt-2 d-none"></div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div class="row">
|
| 94 |
+
<!-- Enhanced Voice Selection -->
|
| 95 |
+
<div class="col-md-6 mb-4">
|
| 96 |
+
<label for="voice-select" class="form-label fw-bold d-flex align-items-center">
|
| 97 |
+
<i class="fas fa-microphone me-2 text-primary"></i>
|
| 98 |
+
Voice
|
| 99 |
+
</label>
|
| 100 |
+
<select class="form-select shadow-sm" id="voice-select" required>
|
| 101 |
+
<option value="">Loading voices...</option>
|
| 102 |
+
</select>
|
| 103 |
+
<div class="form-text">
|
| 104 |
+
<span>Choose from available voices</span>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<!-- Enhanced Format Selection -->
|
| 109 |
+
<div class="col-md-6 mb-4">
|
| 110 |
+
<label for="format-select" class="form-label fw-bold d-flex align-items-center">
|
| 111 |
+
<i class="fas fa-file-audio me-2 text-primary"></i>
|
| 112 |
+
Audio Format
|
| 113 |
+
</label>
|
| 114 |
+
<select class="form-select shadow-sm" id="format-select" required>
|
| 115 |
+
<option value="">Loading formats...</option>
|
| 116 |
+
</select>
|
| 117 |
+
<div class="form-text">
|
| 118 |
+
<span>Select your preferred audio format</span>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<!-- Advanced Options -->
|
| 124 |
+
<div class="row">
|
| 125 |
+
<div class="col-md-6 mb-4">
|
| 126 |
+
<label for="max-length-input" class="form-label fw-bold">
|
| 127 |
+
<i class="fas fa-ruler me-2"></i>Max Length
|
| 128 |
+
</label>
|
| 129 |
+
<input
|
| 130 |
+
type="number"
|
| 131 |
+
class="form-control"
|
| 132 |
+
id="max-length-input"
|
| 133 |
+
value="4096"
|
| 134 |
+
min="100"
|
| 135 |
+
max="10000"
|
| 136 |
+
>
|
| 137 |
+
<div class="form-text">
|
| 138 |
+
Maximum characters per request (default: 4096)
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div class="col-md-6 mb-4">
|
| 143 |
+
<label class="form-label fw-bold">
|
| 144 |
+
<i class="fas fa-cog me-2"></i>Options
|
| 145 |
+
</label>
|
| 146 |
+
<div class="form-check">
|
| 147 |
+
<input class="form-check-input" type="checkbox" id="validate-length-check" checked>
|
| 148 |
+
<label class="form-check-label" for="validate-length-check">
|
| 149 |
+
Enable length validation
|
| 150 |
+
</label>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="form-check">
|
| 153 |
+
<input class="form-check-input" type="checkbox" id="auto-split-check">
|
| 154 |
+
<label class="form-check-label" for="auto-split-check">
|
| 155 |
+
Auto-split long text
|
| 156 |
+
</label>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<!-- Instructions (Optional) -->
|
| 162 |
+
<div class="mb-4">
|
| 163 |
+
<label for="instructions-input" class="form-label fw-bold">
|
| 164 |
+
<i class="fas fa-magic me-2"></i>Instructions (Optional)
|
| 165 |
+
</label>
|
| 166 |
+
<input
|
| 167 |
+
type="text"
|
| 168 |
+
class="form-control"
|
| 169 |
+
id="instructions-input"
|
| 170 |
+
placeholder="e.g., Speak in a cheerful and upbeat tone"
|
| 171 |
+
>
|
| 172 |
+
<div class="form-text">
|
| 173 |
+
Provide optional instructions for voice modulation
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<!-- Enhanced Generate Button -->
|
| 178 |
+
<div class="text-center mb-4">
|
| 179 |
+
<div class="d-grid gap-2 d-md-block">
|
| 180 |
+
<button type="submit" class="btn btn-primary btn-lg px-4 py-3" id="generate-btn">
|
| 181 |
+
<span class="btn-text">
|
| 182 |
+
<i class="fas fa-magic me-2"></i>Generate Speech
|
| 183 |
+
</span>
|
| 184 |
+
<span class="loading-spinner">
|
| 185 |
+
<i class="fas fa-spinner fa-spin me-2"></i>Generating...
|
| 186 |
+
</span>
|
| 187 |
+
</button>
|
| 188 |
+
<button type="button" class="btn btn-outline-secondary btn-lg ms-md-3" id="reset-form-btn">
|
| 189 |
+
<i class="fas fa-redo me-2"></i>Reset
|
| 190 |
+
</button>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
</form>
|
| 194 |
+
|
| 195 |
+
<!-- Enhanced Audio Player -->
|
| 196 |
+
<div id="audio-result" class="d-none">
|
| 197 |
+
<div class="border-top pt-4 mt-4">
|
| 198 |
+
<div class="d-flex align-items-center justify-content-between mb-3">
|
| 199 |
+
<h5 class="mb-0 d-flex align-items-center">
|
| 200 |
+
<i class="fas fa-volume-up me-2 text-success"></i>
|
| 201 |
+
Generated Audio
|
| 202 |
+
<span class="badge bg-success ms-2">
|
| 203 |
+
<i class="fas fa-check me-1"></i>Ready
|
| 204 |
+
</span>
|
| 205 |
+
</h5>
|
| 206 |
+
<div class="btn-group" role="group">
|
| 207 |
+
<button type="button" class="btn btn-sm btn-outline-primary" id="replay-btn" title="Replay audio">
|
| 208 |
+
<i class="fas fa-redo"></i>
|
| 209 |
+
</button>
|
| 210 |
+
<button type="button" class="btn btn-sm btn-outline-secondary" id="share-btn" title="Share audio">
|
| 211 |
+
<i class="fas fa-share"></i>
|
| 212 |
+
</button>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
<div class="audio-player-container bg-light rounded p-3 mb-3">
|
| 217 |
+
<audio controls class="audio-player w-100" id="audio-player" preload="metadata">
|
| 218 |
+
Your browser does not support the audio element.
|
| 219 |
+
</audio>
|
| 220 |
+
<div class="audio-controls mt-2 d-flex justify-content-between align-items-center">
|
| 221 |
+
<div class="audio-info">
|
| 222 |
+
<span id="audio-info" class="text-muted small"></span>
|
| 223 |
+
</div>
|
| 224 |
+
<div class="audio-actions">
|
| 225 |
+
<button type="button" class="btn btn-success btn-sm" id="download-btn">
|
| 226 |
+
<i class="fas fa-download me-1"></i>Download
|
| 227 |
+
</button>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div class="audio-stats row text-center">
|
| 233 |
+
<div class="col-md-3 col-6">
|
| 234 |
+
<div class="stat-item">
|
| 235 |
+
<i class="fas fa-clock text-primary"></i>
|
| 236 |
+
<div class="stat-value" id="audio-duration">--</div>
|
| 237 |
+
<div class="stat-label">Duration</div>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
<div class="col-md-3 col-6">
|
| 241 |
+
<div class="stat-item">
|
| 242 |
+
<i class="fas fa-file text-info"></i>
|
| 243 |
+
<div class="stat-value" id="audio-size">--</div>
|
| 244 |
+
<div class="stat-label">File Size</div>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
<div class="col-md-3 col-6">
|
| 248 |
+
<div class="stat-item">
|
| 249 |
+
<i class="fas fa-microphone text-warning"></i>
|
| 250 |
+
<div class="stat-value" id="audio-voice">--</div>
|
| 251 |
+
<div class="stat-label">Voice</div>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
<div class="col-md-3 col-6">
|
| 255 |
+
<div class="stat-item">
|
| 256 |
+
<i class="fas fa-music text-success"></i>
|
| 257 |
+
<div class="stat-value" id="audio-format">--</div>
|
| 258 |
+
<div class="stat-label">Format</div>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<!-- Batch Results -->
|
| 266 |
+
<div id="batch-result" class="d-none">
|
| 267 |
+
<hr>
|
| 268 |
+
<h5 class="mb-3">
|
| 269 |
+
<i class="fas fa-layer-group me-2"></i>Batch Processing Results
|
| 270 |
+
</h5>
|
| 271 |
+
<div class="alert alert-info" id="batch-summary"></div>
|
| 272 |
+
<div id="batch-chunks" class="row g-3"></div>
|
| 273 |
+
<div class="mt-3">
|
| 274 |
+
<button type="button" class="btn btn-outline-primary" id="download-all-btn">
|
| 275 |
+
<i class="fas fa-download me-2"></i>Download All Audio Files
|
| 276 |
+
</button>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
{% endblock %}
|
| 285 |
+
|
| 286 |
+
{% block extra_js %}
|
| 287 |
+
<!-- Playground JavaScript -->
|
| 288 |
+
<script src="{{ url_for('static', filename='js/playground.js') }}"></script>
|
| 289 |
+
<script>
|
| 290 |
+
// Additional playground-specific functionality
|
| 291 |
+
console.log('TTSFM Playground loaded successfully!');
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
</script>
|
| 295 |
+
{% endblock %}
|
ttsfm/__init__.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TTSFM - Text-to-Speech for Free using OpenAI.fm
|
| 3 |
+
|
| 4 |
+
A Python library for generating high-quality text-to-speech audio using the free OpenAI.fm service.
|
| 5 |
+
Supports multiple voices and audio formats with a simple, intuitive API.
|
| 6 |
+
|
| 7 |
+
Features:
|
| 8 |
+
- 🎤 6 premium AI voices (alloy, echo, fable, nova, onyx, shimmer)
|
| 9 |
+
- 🎵 6 audio formats (MP3, WAV, OPUS, AAC, FLAC, PCM)
|
| 10 |
+
- 🚀 Fast and reliable speech generation
|
| 11 |
+
- 📝 Comprehensive text processing and validation
|
| 12 |
+
- 🔄 Automatic retry with exponential backoff
|
| 13 |
+
- 📊 Detailed response metadata and statistics
|
| 14 |
+
- 🌐 Both synchronous and asynchronous APIs
|
| 15 |
+
- 🎯 OpenAI-compatible API format
|
| 16 |
+
- 🔧 Smart format optimization for best quality
|
| 17 |
+
|
| 18 |
+
Audio Format Support:
|
| 19 |
+
- MP3: Good quality, small file size - ideal for web and general use
|
| 20 |
+
- WAV: Lossless quality, large file size - ideal for professional use
|
| 21 |
+
- OPUS: High-quality compressed audio - ideal for streaming
|
| 22 |
+
- AAC: Advanced audio codec - ideal for mobile devices
|
| 23 |
+
- FLAC: Lossless compression - ideal for archival
|
| 24 |
+
- PCM: Raw audio data - ideal for processing
|
| 25 |
+
|
| 26 |
+
Example:
|
| 27 |
+
>>> from ttsfm import TTSClient, Voice, AudioFormat
|
| 28 |
+
>>>
|
| 29 |
+
>>> client = TTSClient()
|
| 30 |
+
>>>
|
| 31 |
+
>>> # Generate MP3 audio
|
| 32 |
+
>>> mp3_response = client.generate_speech(
|
| 33 |
+
... text="Hello, world!",
|
| 34 |
+
... voice=Voice.ALLOY,
|
| 35 |
+
... response_format=AudioFormat.MP3
|
| 36 |
+
... )
|
| 37 |
+
>>> mp3_response.save_to_file("hello") # Saves as hello.mp3
|
| 38 |
+
>>>
|
| 39 |
+
>>> # Generate WAV audio
|
| 40 |
+
>>> wav_response = client.generate_speech(
|
| 41 |
+
... text="High quality audio",
|
| 42 |
+
... voice=Voice.NOVA,
|
| 43 |
+
... response_format=AudioFormat.WAV
|
| 44 |
+
... )
|
| 45 |
+
>>> wav_response.save_to_file("audio") # Saves as audio.wav
|
| 46 |
+
>>>
|
| 47 |
+
>>> # Generate OPUS audio
|
| 48 |
+
>>> opus_response = client.generate_speech(
|
| 49 |
+
... text="Compressed audio",
|
| 50 |
+
... voice=Voice.ECHO,
|
| 51 |
+
... response_format=AudioFormat.OPUS
|
| 52 |
+
... )
|
| 53 |
+
>>> opus_response.save_to_file("compressed") # Saves as compressed.wav
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
from .client import TTSClient
|
| 57 |
+
from .async_client import AsyncTTSClient
|
| 58 |
+
from .models import (
|
| 59 |
+
TTSRequest,
|
| 60 |
+
TTSResponse,
|
| 61 |
+
Voice,
|
| 62 |
+
AudioFormat,
|
| 63 |
+
TTSError,
|
| 64 |
+
APIError,
|
| 65 |
+
NetworkError,
|
| 66 |
+
ValidationError
|
| 67 |
+
)
|
| 68 |
+
from .exceptions import (
|
| 69 |
+
TTSException,
|
| 70 |
+
APIException,
|
| 71 |
+
NetworkException,
|
| 72 |
+
ValidationException,
|
| 73 |
+
RateLimitException,
|
| 74 |
+
AuthenticationException
|
| 75 |
+
)
|
| 76 |
+
from .utils import (
|
| 77 |
+
validate_text_length,
|
| 78 |
+
split_text_by_length
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
__version__ = "3.0.0"
|
| 82 |
+
__author__ = "dbcccc"
|
| 83 |
+
__email__ = "[email protected]"
|
| 84 |
+
__description__ = "Text-to-Speech API Client with OpenAI compatibility"
|
| 85 |
+
__url__ = "https://github.com/dbccccccc/ttsfm"
|
| 86 |
+
|
| 87 |
+
# Default client instance for convenience
|
| 88 |
+
default_client = None
|
| 89 |
+
|
| 90 |
+
def create_client(base_url: str = None, api_key: str = None, **kwargs) -> TTSClient:
|
| 91 |
+
"""
|
| 92 |
+
Create a new TTS client instance.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
base_url: Base URL for the TTS service
|
| 96 |
+
api_key: API key for authentication (if required)
|
| 97 |
+
**kwargs: Additional client configuration
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
TTSClient: Configured client instance
|
| 101 |
+
"""
|
| 102 |
+
return TTSClient(base_url=base_url, api_key=api_key, **kwargs)
|
| 103 |
+
|
| 104 |
+
def create_async_client(base_url: str = None, api_key: str = None, **kwargs) -> AsyncTTSClient:
|
| 105 |
+
"""
|
| 106 |
+
Create a new async TTS client instance.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
base_url: Base URL for the TTS service
|
| 110 |
+
api_key: API key for authentication (if required)
|
| 111 |
+
**kwargs: Additional client configuration
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
AsyncTTSClient: Configured async client instance
|
| 115 |
+
"""
|
| 116 |
+
return AsyncTTSClient(base_url=base_url, api_key=api_key, **kwargs)
|
| 117 |
+
|
| 118 |
+
def set_default_client(client: TTSClient) -> None:
|
| 119 |
+
"""Set the default client instance for convenience functions."""
|
| 120 |
+
global default_client
|
| 121 |
+
default_client = client
|
| 122 |
+
|
| 123 |
+
def generate_speech(text: str, voice: str = "alloy", **kwargs) -> bytes:
|
| 124 |
+
"""
|
| 125 |
+
Convenience function to generate speech using the default client.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
text: Text to convert to speech
|
| 129 |
+
voice: Voice to use for generation
|
| 130 |
+
**kwargs: Additional generation parameters
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
bytes: Generated audio data
|
| 134 |
+
|
| 135 |
+
Raises:
|
| 136 |
+
TTSException: If no default client is set or generation fails
|
| 137 |
+
"""
|
| 138 |
+
if default_client is None:
|
| 139 |
+
raise TTSException("No default client set. Use create_client() first.")
|
| 140 |
+
|
| 141 |
+
return default_client.generate_speech(text=text, voice=voice, **kwargs)
|
| 142 |
+
|
| 143 |
+
# Export all public components
|
| 144 |
+
__all__ = [
|
| 145 |
+
# Main classes
|
| 146 |
+
"TTSClient",
|
| 147 |
+
"AsyncTTSClient",
|
| 148 |
+
|
| 149 |
+
# Models
|
| 150 |
+
"TTSRequest",
|
| 151 |
+
"TTSResponse",
|
| 152 |
+
"Voice",
|
| 153 |
+
"AudioFormat",
|
| 154 |
+
"TTSError",
|
| 155 |
+
"APIError",
|
| 156 |
+
"NetworkError",
|
| 157 |
+
"ValidationError",
|
| 158 |
+
|
| 159 |
+
# Exceptions
|
| 160 |
+
"TTSException",
|
| 161 |
+
"APIException",
|
| 162 |
+
"NetworkException",
|
| 163 |
+
"ValidationException",
|
| 164 |
+
"RateLimitException",
|
| 165 |
+
"AuthenticationException",
|
| 166 |
+
|
| 167 |
+
# Factory functions
|
| 168 |
+
"create_client",
|
| 169 |
+
"create_async_client",
|
| 170 |
+
"set_default_client",
|
| 171 |
+
"generate_speech",
|
| 172 |
+
|
| 173 |
+
# Utility functions
|
| 174 |
+
"validate_text_length",
|
| 175 |
+
"split_text_by_length",
|
| 176 |
+
|
| 177 |
+
# Package metadata
|
| 178 |
+
"__version__",
|
| 179 |
+
"__author__",
|
| 180 |
+
"__email__",
|
| 181 |
+
"__description__",
|
| 182 |
+
"__url__"
|
| 183 |
+
]
|
ttsfm/async_client.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Asynchronous TTS client implementation.
|
| 3 |
+
|
| 4 |
+
This module provides the AsyncTTSClient class for asynchronous
|
| 5 |
+
text-to-speech generation with OpenAI-compatible API.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import uuid
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Optional, Dict, Any, Union, List
|
| 13 |
+
|
| 14 |
+
import aiohttp
|
| 15 |
+
from aiohttp import ClientTimeout, ClientSession
|
| 16 |
+
|
| 17 |
+
from .models import (
|
| 18 |
+
TTSRequest, TTSResponse, Voice, AudioFormat,
|
| 19 |
+
get_content_type, get_format_from_content_type
|
| 20 |
+
)
|
| 21 |
+
from .exceptions import (
|
| 22 |
+
TTSException, APIException, NetworkException, ValidationException,
|
| 23 |
+
create_exception_from_response
|
| 24 |
+
)
|
| 25 |
+
from .utils import (
|
| 26 |
+
get_realistic_headers, sanitize_text, validate_url, build_url,
|
| 27 |
+
exponential_backoff, estimate_audio_duration, format_file_size,
|
| 28 |
+
validate_text_length, split_text_by_length
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class AsyncTTSClient:
|
| 36 |
+
"""
|
| 37 |
+
Asynchronous TTS client for text-to-speech generation.
|
| 38 |
+
|
| 39 |
+
This client provides an async interface for generating speech from text
|
| 40 |
+
using OpenAI-compatible TTS services with support for concurrent requests.
|
| 41 |
+
|
| 42 |
+
Attributes:
|
| 43 |
+
base_url: Base URL for the TTS service
|
| 44 |
+
api_key: API key for authentication (if required)
|
| 45 |
+
timeout: Request timeout in seconds
|
| 46 |
+
max_retries: Maximum number of retry attempts
|
| 47 |
+
verify_ssl: Whether to verify SSL certificates
|
| 48 |
+
max_concurrent: Maximum concurrent requests
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(
|
| 52 |
+
self,
|
| 53 |
+
base_url: str = "https://www.openai.fm",
|
| 54 |
+
api_key: Optional[str] = None,
|
| 55 |
+
timeout: float = 30.0,
|
| 56 |
+
max_retries: int = 3,
|
| 57 |
+
verify_ssl: bool = True,
|
| 58 |
+
max_concurrent: int = 10,
|
| 59 |
+
**kwargs
|
| 60 |
+
):
|
| 61 |
+
"""
|
| 62 |
+
Initialize the async TTS client.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
base_url: Base URL for the TTS service
|
| 66 |
+
api_key: API key for authentication
|
| 67 |
+
timeout: Request timeout in seconds
|
| 68 |
+
max_retries: Maximum retry attempts
|
| 69 |
+
verify_ssl: Whether to verify SSL certificates
|
| 70 |
+
max_concurrent: Maximum concurrent requests
|
| 71 |
+
**kwargs: Additional configuration options
|
| 72 |
+
"""
|
| 73 |
+
self.base_url = base_url.rstrip('/')
|
| 74 |
+
self.api_key = api_key
|
| 75 |
+
self.timeout = timeout
|
| 76 |
+
self.max_retries = max_retries
|
| 77 |
+
self.verify_ssl = verify_ssl
|
| 78 |
+
self.max_concurrent = max_concurrent
|
| 79 |
+
|
| 80 |
+
# Validate base URL
|
| 81 |
+
if not validate_url(self.base_url):
|
| 82 |
+
raise ValidationException(f"Invalid base URL: {self.base_url}")
|
| 83 |
+
|
| 84 |
+
# Session will be created when needed
|
| 85 |
+
self._session: Optional[ClientSession] = None
|
| 86 |
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
| 87 |
+
|
| 88 |
+
logger.info(f"Initialized async TTS client with base URL: {self.base_url}")
|
| 89 |
+
|
| 90 |
+
async def __aenter__(self):
|
| 91 |
+
"""Async context manager entry."""
|
| 92 |
+
await self._ensure_session()
|
| 93 |
+
return self
|
| 94 |
+
|
| 95 |
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 96 |
+
"""Async context manager exit."""
|
| 97 |
+
await self.close()
|
| 98 |
+
|
| 99 |
+
async def _ensure_session(self):
|
| 100 |
+
"""Ensure HTTP session is created."""
|
| 101 |
+
if self._session is None or self._session.closed:
|
| 102 |
+
# Setup headers
|
| 103 |
+
headers = get_realistic_headers()
|
| 104 |
+
if self.api_key:
|
| 105 |
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
| 106 |
+
|
| 107 |
+
# Create timeout configuration
|
| 108 |
+
timeout = ClientTimeout(total=self.timeout)
|
| 109 |
+
|
| 110 |
+
# Create session
|
| 111 |
+
connector = aiohttp.TCPConnector(
|
| 112 |
+
verify_ssl=self.verify_ssl,
|
| 113 |
+
limit=self.max_concurrent * 2
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
self._session = ClientSession(
|
| 117 |
+
headers=headers,
|
| 118 |
+
timeout=timeout,
|
| 119 |
+
connector=connector
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
async def generate_speech(
|
| 123 |
+
self,
|
| 124 |
+
text: str,
|
| 125 |
+
voice: Union[Voice, str] = Voice.ALLOY,
|
| 126 |
+
response_format: Union[AudioFormat, str] = AudioFormat.MP3,
|
| 127 |
+
instructions: Optional[str] = None,
|
| 128 |
+
max_length: int = 4096,
|
| 129 |
+
validate_length: bool = True,
|
| 130 |
+
**kwargs
|
| 131 |
+
) -> TTSResponse:
|
| 132 |
+
"""
|
| 133 |
+
Generate speech from text asynchronously.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
text: Text to convert to speech
|
| 137 |
+
voice: Voice to use for generation
|
| 138 |
+
response_format: Audio format for output
|
| 139 |
+
instructions: Optional instructions for voice modulation
|
| 140 |
+
max_length: Maximum allowed text length in characters (default: 4096)
|
| 141 |
+
validate_length: Whether to validate text length (default: True)
|
| 142 |
+
**kwargs: Additional parameters
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
TTSResponse: Generated audio response
|
| 146 |
+
|
| 147 |
+
Raises:
|
| 148 |
+
TTSException: If generation fails
|
| 149 |
+
ValueError: If text exceeds max_length and validate_length is True
|
| 150 |
+
"""
|
| 151 |
+
# Create and validate request
|
| 152 |
+
request = TTSRequest(
|
| 153 |
+
input=sanitize_text(text),
|
| 154 |
+
voice=voice,
|
| 155 |
+
response_format=response_format,
|
| 156 |
+
instructions=instructions,
|
| 157 |
+
max_length=max_length,
|
| 158 |
+
validate_length=validate_length,
|
| 159 |
+
**kwargs
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
return await self._make_request(request)
|
| 163 |
+
|
| 164 |
+
async def generate_speech_long_text(
|
| 165 |
+
self,
|
| 166 |
+
text: str,
|
| 167 |
+
voice: Union[Voice, str] = Voice.ALLOY,
|
| 168 |
+
response_format: Union[AudioFormat, str] = AudioFormat.MP3,
|
| 169 |
+
instructions: Optional[str] = None,
|
| 170 |
+
max_length: int = 4096,
|
| 171 |
+
preserve_words: bool = True,
|
| 172 |
+
**kwargs
|
| 173 |
+
) -> List[TTSResponse]:
|
| 174 |
+
"""
|
| 175 |
+
Generate speech from long text by splitting it into chunks asynchronously.
|
| 176 |
+
|
| 177 |
+
This method automatically splits text that exceeds max_length into
|
| 178 |
+
smaller chunks and generates speech for each chunk concurrently.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
text: Text to convert to speech
|
| 182 |
+
voice: Voice to use for generation
|
| 183 |
+
response_format: Audio format for output
|
| 184 |
+
instructions: Optional instructions for voice modulation
|
| 185 |
+
max_length: Maximum length per chunk (default: 4096)
|
| 186 |
+
preserve_words: Whether to avoid splitting words (default: True)
|
| 187 |
+
**kwargs: Additional parameters
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
List[TTSResponse]: List of generated audio responses
|
| 191 |
+
|
| 192 |
+
Raises:
|
| 193 |
+
TTSException: If generation fails for any chunk
|
| 194 |
+
"""
|
| 195 |
+
# Sanitize text first
|
| 196 |
+
clean_text = sanitize_text(text)
|
| 197 |
+
|
| 198 |
+
# Split text into chunks
|
| 199 |
+
chunks = split_text_by_length(clean_text, max_length, preserve_words)
|
| 200 |
+
|
| 201 |
+
if not chunks:
|
| 202 |
+
raise ValueError("No valid text chunks found after processing")
|
| 203 |
+
|
| 204 |
+
# Create requests for all chunks
|
| 205 |
+
requests = []
|
| 206 |
+
for chunk in chunks:
|
| 207 |
+
request = TTSRequest(
|
| 208 |
+
input=chunk,
|
| 209 |
+
voice=voice,
|
| 210 |
+
response_format=response_format,
|
| 211 |
+
instructions=instructions,
|
| 212 |
+
max_length=max_length,
|
| 213 |
+
validate_length=False, # We already split the text
|
| 214 |
+
**kwargs
|
| 215 |
+
)
|
| 216 |
+
requests.append(request)
|
| 217 |
+
|
| 218 |
+
# Process all chunks concurrently
|
| 219 |
+
return await self.generate_speech_batch(requests)
|
| 220 |
+
|
| 221 |
+
async def generate_speech_batch(
|
| 222 |
+
self,
|
| 223 |
+
requests: List[TTSRequest]
|
| 224 |
+
) -> List[TTSResponse]:
|
| 225 |
+
"""
|
| 226 |
+
Generate speech for multiple requests concurrently.
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
requests: List of TTS requests
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
List[TTSResponse]: List of generated audio responses
|
| 233 |
+
|
| 234 |
+
Raises:
|
| 235 |
+
TTSException: If any generation fails
|
| 236 |
+
"""
|
| 237 |
+
if not requests:
|
| 238 |
+
return []
|
| 239 |
+
|
| 240 |
+
# Process requests concurrently with semaphore limiting
|
| 241 |
+
tasks = [self._make_request(request) for request in requests]
|
| 242 |
+
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
| 243 |
+
|
| 244 |
+
# Check for exceptions and convert them
|
| 245 |
+
results = []
|
| 246 |
+
for i, response in enumerate(responses):
|
| 247 |
+
if isinstance(response, Exception):
|
| 248 |
+
raise TTSException(f"Request {i} failed: {str(response)}")
|
| 249 |
+
results.append(response)
|
| 250 |
+
|
| 251 |
+
return results
|
| 252 |
+
|
| 253 |
+
async def generate_speech_from_request(self, request: TTSRequest) -> TTSResponse:
|
| 254 |
+
"""
|
| 255 |
+
Generate speech from a TTSRequest object asynchronously.
|
| 256 |
+
|
| 257 |
+
Args:
|
| 258 |
+
request: TTS request object
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
TTSResponse: Generated audio response
|
| 262 |
+
"""
|
| 263 |
+
return await self._make_request(request)
|
| 264 |
+
|
| 265 |
+
async def _make_request(self, request: TTSRequest) -> TTSResponse:
|
| 266 |
+
"""
|
| 267 |
+
Make the actual HTTP request to the TTS service.
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
request: TTS request object
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
TTSResponse: Generated audio response
|
| 274 |
+
|
| 275 |
+
Raises:
|
| 276 |
+
TTSException: If request fails
|
| 277 |
+
"""
|
| 278 |
+
await self._ensure_session()
|
| 279 |
+
|
| 280 |
+
async with self._semaphore: # Limit concurrent requests
|
| 281 |
+
url = build_url(self.base_url, "api/generate")
|
| 282 |
+
|
| 283 |
+
# Prepare form data for openai.fm API
|
| 284 |
+
form_data = {
|
| 285 |
+
'input': request.input,
|
| 286 |
+
'voice': request.voice.value,
|
| 287 |
+
'generation': str(uuid.uuid4()),
|
| 288 |
+
'response_format': request.response_format.value if hasattr(request.response_format, 'value') else str(request.response_format)
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
# Add prompt/instructions if provided
|
| 292 |
+
if request.instructions:
|
| 293 |
+
form_data['prompt'] = request.instructions
|
| 294 |
+
else:
|
| 295 |
+
# Default prompt for better quality
|
| 296 |
+
form_data['prompt'] = (
|
| 297 |
+
"Affect/personality: Natural and clear\n\n"
|
| 298 |
+
"Tone: Friendly and professional, creating a pleasant listening experience.\n\n"
|
| 299 |
+
"Pronunciation: Clear, articulate, and steady, ensuring each word is easily understood "
|
| 300 |
+
"while maintaining a natural, conversational flow.\n\n"
|
| 301 |
+
"Pause: Brief, purposeful pauses between sentences to allow time for the listener "
|
| 302 |
+
"to process the information.\n\n"
|
| 303 |
+
"Emotion: Warm and engaging, conveying the intended message effectively."
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
logger.info(f"Generating speech for text: '{request.input[:50]}...' with voice: {request.voice}")
|
| 307 |
+
|
| 308 |
+
# Make request with retries
|
| 309 |
+
for attempt in range(self.max_retries + 1):
|
| 310 |
+
try:
|
| 311 |
+
# Add random delay for rate limiting (except first attempt)
|
| 312 |
+
if attempt > 0:
|
| 313 |
+
delay = exponential_backoff(attempt - 1)
|
| 314 |
+
logger.info(f"Retrying request after {delay:.2f}s (attempt {attempt + 1})")
|
| 315 |
+
await asyncio.sleep(delay)
|
| 316 |
+
|
| 317 |
+
# Use form data as required by openai.fm
|
| 318 |
+
async with self._session.post(url, data=form_data) as response:
|
| 319 |
+
# Handle different response types
|
| 320 |
+
if response.status == 200:
|
| 321 |
+
return await self._process_openai_fm_response(response, request)
|
| 322 |
+
else:
|
| 323 |
+
# Try to parse error response
|
| 324 |
+
try:
|
| 325 |
+
error_data = await response.json()
|
| 326 |
+
except (json.JSONDecodeError, ValueError):
|
| 327 |
+
text = await response.text()
|
| 328 |
+
error_data = {"error": {"message": text or "Unknown error"}}
|
| 329 |
+
|
| 330 |
+
# Create appropriate exception
|
| 331 |
+
exception = create_exception_from_response(
|
| 332 |
+
response.status,
|
| 333 |
+
error_data,
|
| 334 |
+
f"TTS request failed with status {response.status}"
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
# Don't retry for certain errors
|
| 338 |
+
if response.status in [400, 401, 403, 404]:
|
| 339 |
+
raise exception
|
| 340 |
+
|
| 341 |
+
# For retryable errors, continue to next attempt
|
| 342 |
+
if attempt == self.max_retries:
|
| 343 |
+
raise exception
|
| 344 |
+
|
| 345 |
+
logger.warning(f"Request failed with status {response.status}, retrying...")
|
| 346 |
+
continue
|
| 347 |
+
|
| 348 |
+
except asyncio.TimeoutError:
|
| 349 |
+
if attempt == self.max_retries:
|
| 350 |
+
raise NetworkException(
|
| 351 |
+
f"Request timed out after {self.timeout}s",
|
| 352 |
+
timeout=self.timeout,
|
| 353 |
+
retry_count=attempt
|
| 354 |
+
)
|
| 355 |
+
logger.warning(f"Request timed out, retrying...")
|
| 356 |
+
continue
|
| 357 |
+
|
| 358 |
+
except aiohttp.ClientError as e:
|
| 359 |
+
if attempt == self.max_retries:
|
| 360 |
+
raise NetworkException(
|
| 361 |
+
f"Client error: {str(e)}",
|
| 362 |
+
retry_count=attempt
|
| 363 |
+
)
|
| 364 |
+
logger.warning(f"Client error, retrying...")
|
| 365 |
+
continue
|
| 366 |
+
|
| 367 |
+
# This should never be reached, but just in case
|
| 368 |
+
raise TTSException("Maximum retries exceeded")
|
| 369 |
+
|
| 370 |
+
async def _process_openai_fm_response(
|
| 371 |
+
self,
|
| 372 |
+
response: aiohttp.ClientResponse,
|
| 373 |
+
request: TTSRequest
|
| 374 |
+
) -> TTSResponse:
|
| 375 |
+
"""
|
| 376 |
+
Process a successful response from the openai.fm TTS service.
|
| 377 |
+
|
| 378 |
+
Args:
|
| 379 |
+
response: HTTP response object
|
| 380 |
+
request: Original TTS request
|
| 381 |
+
|
| 382 |
+
Returns:
|
| 383 |
+
TTSResponse: Processed response object
|
| 384 |
+
"""
|
| 385 |
+
# Get content type from response headers
|
| 386 |
+
content_type = response.headers.get("content-type", "audio/mpeg")
|
| 387 |
+
|
| 388 |
+
# Get audio data
|
| 389 |
+
audio_data = await response.read()
|
| 390 |
+
|
| 391 |
+
if not audio_data:
|
| 392 |
+
raise APIException("Received empty audio data from openai.fm")
|
| 393 |
+
|
| 394 |
+
# Determine format from content type
|
| 395 |
+
if "audio/mpeg" in content_type or "audio/mp3" in content_type:
|
| 396 |
+
actual_format = AudioFormat.MP3
|
| 397 |
+
elif "audio/wav" in content_type:
|
| 398 |
+
actual_format = AudioFormat.WAV
|
| 399 |
+
elif "audio/opus" in content_type:
|
| 400 |
+
actual_format = AudioFormat.OPUS
|
| 401 |
+
elif "audio/aac" in content_type:
|
| 402 |
+
actual_format = AudioFormat.AAC
|
| 403 |
+
elif "audio/flac" in content_type:
|
| 404 |
+
actual_format = AudioFormat.FLAC
|
| 405 |
+
else:
|
| 406 |
+
# Default to MP3 for openai.fm
|
| 407 |
+
actual_format = AudioFormat.MP3
|
| 408 |
+
|
| 409 |
+
# Estimate duration based on text length
|
| 410 |
+
estimated_duration = estimate_audio_duration(request.input)
|
| 411 |
+
|
| 412 |
+
# Check if returned format differs from requested format
|
| 413 |
+
requested_format = request.response_format
|
| 414 |
+
if isinstance(requested_format, str):
|
| 415 |
+
try:
|
| 416 |
+
requested_format = AudioFormat(requested_format.lower())
|
| 417 |
+
except ValueError:
|
| 418 |
+
requested_format = AudioFormat.MP3 # Default fallback
|
| 419 |
+
|
| 420 |
+
# Import here to avoid circular imports
|
| 421 |
+
from .models import maps_to_wav
|
| 422 |
+
|
| 423 |
+
# Check if format differs from request
|
| 424 |
+
if actual_format != requested_format:
|
| 425 |
+
if maps_to_wav(requested_format.value) and actual_format.value == "wav":
|
| 426 |
+
logger.debug(
|
| 427 |
+
f"Format '{requested_format.value}' requested, returning WAV format."
|
| 428 |
+
)
|
| 429 |
+
else:
|
| 430 |
+
logger.warning(
|
| 431 |
+
f"Requested format '{requested_format.value}' but received '{actual_format.value}' "
|
| 432 |
+
f"from service."
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
# Create response object
|
| 436 |
+
tts_response = TTSResponse(
|
| 437 |
+
audio_data=audio_data,
|
| 438 |
+
content_type=content_type,
|
| 439 |
+
format=actual_format,
|
| 440 |
+
size=len(audio_data),
|
| 441 |
+
duration=estimated_duration,
|
| 442 |
+
metadata={
|
| 443 |
+
"response_headers": dict(response.headers),
|
| 444 |
+
"status_code": response.status,
|
| 445 |
+
"url": str(response.url),
|
| 446 |
+
"service": "openai.fm",
|
| 447 |
+
"voice": request.voice.value,
|
| 448 |
+
"original_text": request.input[:100] + "..." if len(request.input) > 100 else request.input,
|
| 449 |
+
"requested_format": requested_format.value,
|
| 450 |
+
"actual_format": actual_format.value
|
| 451 |
+
}
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
logger.info(
|
| 455 |
+
f"Successfully generated {format_file_size(len(audio_data))} "
|
| 456 |
+
f"of {actual_format.value.upper()} audio from openai.fm using voice '{request.voice.value}'"
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
return tts_response
|
| 460 |
+
|
| 461 |
+
async def close(self):
|
| 462 |
+
"""Close the HTTP session."""
|
| 463 |
+
if self._session and not self._session.closed:
|
| 464 |
+
await self._session.close()
|
ttsfm/cli.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Command-line interface for TTSFM.
|
| 4 |
+
|
| 5 |
+
This module provides a command-line interface for the TTSFM package,
|
| 6 |
+
allowing users to generate speech from text using various options.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import sys
|
| 11 |
+
import os
|
| 12 |
+
from typing import Optional
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from .client import TTSClient
|
| 16 |
+
from .models import Voice, AudioFormat
|
| 17 |
+
from .exceptions import TTSException, APIException, NetworkException
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def create_parser() -> argparse.ArgumentParser:
|
| 21 |
+
"""Create and configure the argument parser."""
|
| 22 |
+
parser = argparse.ArgumentParser(
|
| 23 |
+
prog="ttsfm",
|
| 24 |
+
description="TTSFM - Text-to-Speech API Client",
|
| 25 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 26 |
+
epilog="""
|
| 27 |
+
Examples:
|
| 28 |
+
ttsfm "Hello, world!" --output hello.mp3
|
| 29 |
+
ttsfm "Hello, world!" --voice nova --format wav --output hello.wav
|
| 30 |
+
ttsfm "Hello, world!" --url http://localhost:7000 --output hello.mp3
|
| 31 |
+
ttsfm --text-file input.txt --output speech.mp3
|
| 32 |
+
"""
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Text input options (mutually exclusive)
|
| 36 |
+
text_group = parser.add_mutually_exclusive_group(required=True)
|
| 37 |
+
text_group.add_argument(
|
| 38 |
+
"text",
|
| 39 |
+
nargs="?",
|
| 40 |
+
help="Text to convert to speech"
|
| 41 |
+
)
|
| 42 |
+
text_group.add_argument(
|
| 43 |
+
"--text-file", "-f",
|
| 44 |
+
type=str,
|
| 45 |
+
help="Read text from file"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Output options
|
| 49 |
+
parser.add_argument(
|
| 50 |
+
"--output", "-o",
|
| 51 |
+
type=str,
|
| 52 |
+
required=True,
|
| 53 |
+
help="Output file path"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# TTS options
|
| 57 |
+
parser.add_argument(
|
| 58 |
+
"--voice", "-v",
|
| 59 |
+
type=str,
|
| 60 |
+
default="alloy",
|
| 61 |
+
choices=["alloy", "echo", "fable", "onyx", "nova", "shimmer"],
|
| 62 |
+
help="Voice to use for speech generation (default: alloy)"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
parser.add_argument(
|
| 66 |
+
"--format",
|
| 67 |
+
type=str,
|
| 68 |
+
default="mp3",
|
| 69 |
+
choices=["mp3", "opus", "aac", "flac", "wav", "pcm"],
|
| 70 |
+
help="Audio format (default: mp3)"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
parser.add_argument(
|
| 74 |
+
"--speed",
|
| 75 |
+
type=float,
|
| 76 |
+
default=1.0,
|
| 77 |
+
help="Speech speed (0.25 to 4.0, default: 1.0)"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Client options
|
| 81 |
+
parser.add_argument(
|
| 82 |
+
"--url", "-u",
|
| 83 |
+
type=str,
|
| 84 |
+
default="http://localhost:7000",
|
| 85 |
+
help="TTS service URL (default: http://localhost:7000)"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
parser.add_argument(
|
| 89 |
+
"--api-key", "-k",
|
| 90 |
+
type=str,
|
| 91 |
+
help="API key for authentication"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
parser.add_argument(
|
| 95 |
+
"--timeout",
|
| 96 |
+
type=float,
|
| 97 |
+
default=30.0,
|
| 98 |
+
help="Request timeout in seconds (default: 30.0)"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
parser.add_argument(
|
| 102 |
+
"--retries",
|
| 103 |
+
type=int,
|
| 104 |
+
default=3,
|
| 105 |
+
help="Maximum number of retries (default: 3)"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Text length validation options
|
| 109 |
+
parser.add_argument(
|
| 110 |
+
"--max-length",
|
| 111 |
+
type=int,
|
| 112 |
+
default=4096,
|
| 113 |
+
help="Maximum text length in characters (default: 4096)"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
parser.add_argument(
|
| 117 |
+
"--no-length-validation",
|
| 118 |
+
action="store_true",
|
| 119 |
+
help="Disable text length validation"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
parser.add_argument(
|
| 123 |
+
"--split-long-text",
|
| 124 |
+
action="store_true",
|
| 125 |
+
help="Automatically split long text into chunks"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Other options
|
| 129 |
+
parser.add_argument(
|
| 130 |
+
"--verbose", "-V",
|
| 131 |
+
action="store_true",
|
| 132 |
+
help="Enable verbose output"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
parser.add_argument(
|
| 136 |
+
"--version",
|
| 137 |
+
action="version",
|
| 138 |
+
version=f"%(prog)s {get_version()}"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
return parser
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def get_version() -> str:
|
| 145 |
+
"""Get the package version."""
|
| 146 |
+
try:
|
| 147 |
+
from . import __version__
|
| 148 |
+
return __version__
|
| 149 |
+
except ImportError:
|
| 150 |
+
return "unknown"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def read_text_file(file_path: str) -> str:
|
| 154 |
+
"""Read text from a file."""
|
| 155 |
+
try:
|
| 156 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 157 |
+
return f.read().strip()
|
| 158 |
+
except FileNotFoundError:
|
| 159 |
+
print(f"Error: File '{file_path}' not found.", file=sys.stderr)
|
| 160 |
+
sys.exit(1)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
print(f"Error reading file '{file_path}': {e}", file=sys.stderr)
|
| 163 |
+
sys.exit(1)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def validate_speed(speed: float) -> float:
|
| 167 |
+
"""Validate and return the speed parameter."""
|
| 168 |
+
if not 0.25 <= speed <= 4.0:
|
| 169 |
+
print("Error: Speed must be between 0.25 and 4.0", file=sys.stderr)
|
| 170 |
+
sys.exit(1)
|
| 171 |
+
return speed
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def get_voice_enum(voice_str: str) -> Voice:
|
| 175 |
+
"""Convert voice string to Voice enum."""
|
| 176 |
+
voice_map = {
|
| 177 |
+
"alloy": Voice.ALLOY,
|
| 178 |
+
"echo": Voice.ECHO,
|
| 179 |
+
"fable": Voice.FABLE,
|
| 180 |
+
"onyx": Voice.ONYX,
|
| 181 |
+
"nova": Voice.NOVA,
|
| 182 |
+
"shimmer": Voice.SHIMMER,
|
| 183 |
+
}
|
| 184 |
+
return voice_map[voice_str.lower()]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def get_format_enum(format_str: str) -> AudioFormat:
|
| 188 |
+
"""Convert format string to AudioFormat enum."""
|
| 189 |
+
format_map = {
|
| 190 |
+
"mp3": AudioFormat.MP3,
|
| 191 |
+
"opus": AudioFormat.OPUS,
|
| 192 |
+
"aac": AudioFormat.AAC,
|
| 193 |
+
"flac": AudioFormat.FLAC,
|
| 194 |
+
"wav": AudioFormat.WAV,
|
| 195 |
+
"pcm": AudioFormat.PCM,
|
| 196 |
+
}
|
| 197 |
+
return format_map[format_str.lower()]
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def handle_long_text(args, text: str, voice: Voice, audio_format: AudioFormat, speed: float) -> None:
|
| 201 |
+
"""Handle long text by splitting it into chunks and generating multiple files."""
|
| 202 |
+
from .utils import split_text_by_length
|
| 203 |
+
import os
|
| 204 |
+
|
| 205 |
+
# Split text into chunks
|
| 206 |
+
chunks = split_text_by_length(text, args.max_length, preserve_words=True)
|
| 207 |
+
|
| 208 |
+
if not chunks:
|
| 209 |
+
print("Error: No valid text chunks found after processing.", file=sys.stderr)
|
| 210 |
+
sys.exit(1)
|
| 211 |
+
|
| 212 |
+
print(f"Split text into {len(chunks)} chunks")
|
| 213 |
+
|
| 214 |
+
# Create client
|
| 215 |
+
try:
|
| 216 |
+
client = TTSClient(
|
| 217 |
+
base_url=args.url,
|
| 218 |
+
api_key=args.api_key,
|
| 219 |
+
timeout=args.timeout,
|
| 220 |
+
max_retries=args.retries
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# Generate speech for each chunk
|
| 224 |
+
base_name, ext = os.path.splitext(args.output)
|
| 225 |
+
|
| 226 |
+
for i, chunk in enumerate(chunks, 1):
|
| 227 |
+
if args.verbose:
|
| 228 |
+
print(f"Processing chunk {i}/{len(chunks)} ({len(chunk)} characters)...")
|
| 229 |
+
|
| 230 |
+
# Generate filename for this chunk
|
| 231 |
+
if len(chunks) == 1:
|
| 232 |
+
output_file = args.output
|
| 233 |
+
else:
|
| 234 |
+
output_file = f"{base_name}_part{i:03d}{ext}"
|
| 235 |
+
|
| 236 |
+
# Generate speech for this chunk
|
| 237 |
+
audio_data = client.generate_speech(
|
| 238 |
+
text=chunk,
|
| 239 |
+
voice=voice,
|
| 240 |
+
response_format=audio_format,
|
| 241 |
+
speed=speed,
|
| 242 |
+
max_length=args.max_length,
|
| 243 |
+
validate_length=False # We already split the text
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# Save to file
|
| 247 |
+
with open(output_file, 'wb') as f:
|
| 248 |
+
f.write(audio_data)
|
| 249 |
+
|
| 250 |
+
print(f"Generated: {output_file}")
|
| 251 |
+
|
| 252 |
+
if len(chunks) > 1:
|
| 253 |
+
print(f"\nGenerated {len(chunks)} audio files from long text.")
|
| 254 |
+
print(f"Files: {base_name}_part001{ext} to {base_name}_part{len(chunks):03d}{ext}")
|
| 255 |
+
|
| 256 |
+
except Exception as e:
|
| 257 |
+
print(f"Error processing long text: {e}", file=sys.stderr)
|
| 258 |
+
if args.verbose:
|
| 259 |
+
import traceback
|
| 260 |
+
traceback.print_exc()
|
| 261 |
+
sys.exit(1)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def main() -> None:
|
| 265 |
+
"""Main CLI entry point."""
|
| 266 |
+
parser = create_parser()
|
| 267 |
+
args = parser.parse_args()
|
| 268 |
+
|
| 269 |
+
# Get text input
|
| 270 |
+
if args.text:
|
| 271 |
+
text = args.text
|
| 272 |
+
else:
|
| 273 |
+
text = read_text_file(args.text_file)
|
| 274 |
+
|
| 275 |
+
if not text:
|
| 276 |
+
print("Error: No text provided.", file=sys.stderr)
|
| 277 |
+
sys.exit(1)
|
| 278 |
+
|
| 279 |
+
# Validate parameters
|
| 280 |
+
speed = validate_speed(args.speed)
|
| 281 |
+
voice = get_voice_enum(args.voice)
|
| 282 |
+
audio_format = get_format_enum(args.format)
|
| 283 |
+
|
| 284 |
+
# Create output directory if needed
|
| 285 |
+
output_path = Path(args.output)
|
| 286 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 287 |
+
|
| 288 |
+
# Check text length and handle accordingly
|
| 289 |
+
text_length = len(text)
|
| 290 |
+
validate_length = not args.no_length_validation
|
| 291 |
+
|
| 292 |
+
if args.verbose:
|
| 293 |
+
print(f"Text: {text[:50]}{'...' if len(text) > 50 else ''}")
|
| 294 |
+
print(f"Text length: {text_length} characters")
|
| 295 |
+
print(f"Max length: {args.max_length}")
|
| 296 |
+
print(f"Length validation: {'enabled' if validate_length else 'disabled'}")
|
| 297 |
+
print(f"Voice: {args.voice}")
|
| 298 |
+
print(f"Format: {args.format}")
|
| 299 |
+
print(f"Speed: {speed}")
|
| 300 |
+
print(f"URL: {args.url}")
|
| 301 |
+
print(f"Output: {args.output}")
|
| 302 |
+
print()
|
| 303 |
+
|
| 304 |
+
# Handle long text
|
| 305 |
+
if text_length > args.max_length:
|
| 306 |
+
if args.split_long_text:
|
| 307 |
+
print(f"Text is {text_length} characters, splitting into chunks...")
|
| 308 |
+
return handle_long_text(args, text, voice, audio_format, speed)
|
| 309 |
+
elif validate_length:
|
| 310 |
+
print(f"Error: Text is too long ({text_length} characters). "
|
| 311 |
+
f"Maximum allowed is {args.max_length} characters.", file=sys.stderr)
|
| 312 |
+
print("Use --split-long-text to automatically split the text, "
|
| 313 |
+
"or --no-length-validation to disable this check.", file=sys.stderr)
|
| 314 |
+
sys.exit(1)
|
| 315 |
+
|
| 316 |
+
# Create client
|
| 317 |
+
try:
|
| 318 |
+
client = TTSClient(
|
| 319 |
+
base_url=args.url,
|
| 320 |
+
api_key=args.api_key,
|
| 321 |
+
timeout=args.timeout,
|
| 322 |
+
max_retries=args.retries
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
if args.verbose:
|
| 326 |
+
print("Generating speech...")
|
| 327 |
+
|
| 328 |
+
# Generate speech
|
| 329 |
+
audio_data = client.generate_speech(
|
| 330 |
+
text=text,
|
| 331 |
+
voice=voice,
|
| 332 |
+
response_format=audio_format,
|
| 333 |
+
speed=speed,
|
| 334 |
+
max_length=args.max_length,
|
| 335 |
+
validate_length=validate_length
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# Save to file
|
| 339 |
+
with open(args.output, 'wb') as f:
|
| 340 |
+
f.write(audio_data)
|
| 341 |
+
|
| 342 |
+
print(f"Speech generated successfully: {args.output}")
|
| 343 |
+
|
| 344 |
+
except NetworkException as e:
|
| 345 |
+
print(f"Network error: {e}", file=sys.stderr)
|
| 346 |
+
sys.exit(1)
|
| 347 |
+
except APIException as e:
|
| 348 |
+
print(f"API error: {e}", file=sys.stderr)
|
| 349 |
+
sys.exit(1)
|
| 350 |
+
except TTSException as e:
|
| 351 |
+
print(f"TTS error: {e}", file=sys.stderr)
|
| 352 |
+
sys.exit(1)
|
| 353 |
+
except Exception as e:
|
| 354 |
+
print(f"Unexpected error: {e}", file=sys.stderr)
|
| 355 |
+
if args.verbose:
|
| 356 |
+
import traceback
|
| 357 |
+
traceback.print_exc()
|
| 358 |
+
sys.exit(1)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
if __name__ == "__main__":
|
| 362 |
+
main()
|
ttsfm/client.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main TTS client implementation.
|
| 3 |
+
|
| 4 |
+
This module provides the primary TTSClient class for synchronous
|
| 5 |
+
text-to-speech generation with OpenAI-compatible API.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Optional, Dict, Any, Union, List
|
| 13 |
+
from urllib.parse import urljoin
|
| 14 |
+
|
| 15 |
+
import requests
|
| 16 |
+
from requests.adapters import HTTPAdapter
|
| 17 |
+
from urllib3.util.retry import Retry
|
| 18 |
+
|
| 19 |
+
from .models import (
|
| 20 |
+
TTSRequest, TTSResponse, Voice, AudioFormat,
|
| 21 |
+
get_content_type, get_format_from_content_type
|
| 22 |
+
)
|
| 23 |
+
from .exceptions import (
|
| 24 |
+
TTSException, APIException, NetworkException, ValidationException,
|
| 25 |
+
create_exception_from_response
|
| 26 |
+
)
|
| 27 |
+
from .utils import (
|
| 28 |
+
get_realistic_headers, sanitize_text, validate_url, build_url,
|
| 29 |
+
exponential_backoff, estimate_audio_duration, format_file_size,
|
| 30 |
+
validate_text_length, split_text_by_length
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TTSClient:
|
| 38 |
+
"""
|
| 39 |
+
Synchronous TTS client for text-to-speech generation.
|
| 40 |
+
|
| 41 |
+
This client provides a simple interface for generating speech from text
|
| 42 |
+
using OpenAI-compatible TTS services.
|
| 43 |
+
|
| 44 |
+
Attributes:
|
| 45 |
+
base_url: Base URL for the TTS service
|
| 46 |
+
api_key: API key for authentication (if required)
|
| 47 |
+
timeout: Request timeout in seconds
|
| 48 |
+
max_retries: Maximum number of retry attempts
|
| 49 |
+
verify_ssl: Whether to verify SSL certificates
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
def __init__(
|
| 53 |
+
self,
|
| 54 |
+
base_url: str = "https://www.openai.fm",
|
| 55 |
+
api_key: Optional[str] = None,
|
| 56 |
+
timeout: float = 30.0,
|
| 57 |
+
max_retries: int = 3,
|
| 58 |
+
verify_ssl: bool = True,
|
| 59 |
+
preferred_format: Optional[AudioFormat] = None,
|
| 60 |
+
**kwargs
|
| 61 |
+
):
|
| 62 |
+
"""
|
| 63 |
+
Initialize the TTS client.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
base_url: Base URL for the TTS service
|
| 67 |
+
api_key: API key for authentication
|
| 68 |
+
timeout: Request timeout in seconds
|
| 69 |
+
max_retries: Maximum retry attempts
|
| 70 |
+
verify_ssl: Whether to verify SSL certificates
|
| 71 |
+
preferred_format: Preferred audio format (affects header selection)
|
| 72 |
+
**kwargs: Additional configuration options
|
| 73 |
+
"""
|
| 74 |
+
self.base_url = base_url.rstrip('/')
|
| 75 |
+
self.api_key = api_key
|
| 76 |
+
self.timeout = timeout
|
| 77 |
+
self.max_retries = max_retries
|
| 78 |
+
self.verify_ssl = verify_ssl
|
| 79 |
+
self.preferred_format = preferred_format or AudioFormat.WAV
|
| 80 |
+
|
| 81 |
+
# Validate base URL
|
| 82 |
+
if not validate_url(self.base_url):
|
| 83 |
+
raise ValidationException(f"Invalid base URL: {self.base_url}")
|
| 84 |
+
|
| 85 |
+
# Setup HTTP session with retry strategy
|
| 86 |
+
self.session = requests.Session()
|
| 87 |
+
|
| 88 |
+
# Configure retry strategy
|
| 89 |
+
retry_strategy = Retry(
|
| 90 |
+
total=max_retries,
|
| 91 |
+
status_forcelist=[429, 500, 502, 503, 504],
|
| 92 |
+
allowed_methods=["HEAD", "GET", "POST"], # Updated parameter name
|
| 93 |
+
backoff_factor=1
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
| 97 |
+
self.session.mount("http://", adapter)
|
| 98 |
+
self.session.mount("https://", adapter)
|
| 99 |
+
|
| 100 |
+
# Set default headers
|
| 101 |
+
self.session.headers.update(get_realistic_headers())
|
| 102 |
+
|
| 103 |
+
if self.api_key:
|
| 104 |
+
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
|
| 105 |
+
|
| 106 |
+
logger.info(f"Initialized TTS client with base URL: {self.base_url}")
|
| 107 |
+
|
| 108 |
+
def _get_headers_for_format(self, requested_format: AudioFormat) -> Dict[str, str]:
|
| 109 |
+
"""
|
| 110 |
+
Get appropriate headers to get the desired format from openai.fm.
|
| 111 |
+
|
| 112 |
+
Based on testing, openai.fm returns:
|
| 113 |
+
- MP3: When using simple/minimal headers
|
| 114 |
+
- WAV: When using full Chrome security headers
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
requested_format: The desired audio format
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Dict[str, str]: HTTP headers optimized for the requested format
|
| 121 |
+
"""
|
| 122 |
+
from .models import get_supported_format
|
| 123 |
+
|
| 124 |
+
# Map requested format to supported format
|
| 125 |
+
target_format = get_supported_format(requested_format)
|
| 126 |
+
|
| 127 |
+
if target_format == AudioFormat.MP3:
|
| 128 |
+
# Use minimal headers to get MP3 response
|
| 129 |
+
return {
|
| 130 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
| 131 |
+
'Accept': 'audio/*,*/*;q=0.9'
|
| 132 |
+
}
|
| 133 |
+
else:
|
| 134 |
+
# Use full realistic headers to get WAV response
|
| 135 |
+
# This works for WAV, OPUS, AAC, FLAC, PCM formats
|
| 136 |
+
return get_realistic_headers()
|
| 137 |
+
|
| 138 |
+
def generate_speech(
|
| 139 |
+
self,
|
| 140 |
+
text: str,
|
| 141 |
+
voice: Union[Voice, str] = Voice.ALLOY,
|
| 142 |
+
response_format: Union[AudioFormat, str] = AudioFormat.MP3,
|
| 143 |
+
instructions: Optional[str] = None,
|
| 144 |
+
max_length: int = 4096,
|
| 145 |
+
validate_length: bool = True,
|
| 146 |
+
**kwargs
|
| 147 |
+
) -> TTSResponse:
|
| 148 |
+
"""
|
| 149 |
+
Generate speech from text.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
text: Text to convert to speech
|
| 153 |
+
voice: Voice to use for generation
|
| 154 |
+
response_format: Audio format for output
|
| 155 |
+
instructions: Optional instructions for voice modulation
|
| 156 |
+
max_length: Maximum allowed text length in characters (default: 4096)
|
| 157 |
+
validate_length: Whether to validate text length (default: True)
|
| 158 |
+
**kwargs: Additional parameters
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
TTSResponse: Generated audio response
|
| 162 |
+
|
| 163 |
+
Raises:
|
| 164 |
+
TTSException: If generation fails
|
| 165 |
+
ValueError: If text exceeds max_length and validate_length is True
|
| 166 |
+
"""
|
| 167 |
+
# Create and validate request
|
| 168 |
+
request = TTSRequest(
|
| 169 |
+
input=sanitize_text(text),
|
| 170 |
+
voice=voice,
|
| 171 |
+
response_format=response_format,
|
| 172 |
+
instructions=instructions,
|
| 173 |
+
max_length=max_length,
|
| 174 |
+
validate_length=validate_length,
|
| 175 |
+
**kwargs
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
return self._make_request(request)
|
| 179 |
+
|
| 180 |
+
def generate_speech_from_request(self, request: TTSRequest) -> TTSResponse:
|
| 181 |
+
"""
|
| 182 |
+
Generate speech from a TTSRequest object.
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
request: TTS request object
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
TTSResponse: Generated audio response
|
| 189 |
+
"""
|
| 190 |
+
return self._make_request(request)
|
| 191 |
+
|
| 192 |
+
def generate_speech_batch(
|
| 193 |
+
self,
|
| 194 |
+
text: str,
|
| 195 |
+
voice: Union[Voice, str] = Voice.ALLOY,
|
| 196 |
+
response_format: Union[AudioFormat, str] = AudioFormat.MP3,
|
| 197 |
+
instructions: Optional[str] = None,
|
| 198 |
+
max_length: int = 4096,
|
| 199 |
+
preserve_words: bool = True,
|
| 200 |
+
**kwargs
|
| 201 |
+
) -> List[TTSResponse]:
|
| 202 |
+
"""
|
| 203 |
+
Generate speech from long text by splitting it into chunks.
|
| 204 |
+
|
| 205 |
+
This method automatically splits text that exceeds max_length into
|
| 206 |
+
smaller chunks and generates speech for each chunk separately.
|
| 207 |
+
|
| 208 |
+
Args:
|
| 209 |
+
text: Text to convert to speech
|
| 210 |
+
voice: Voice to use for generation
|
| 211 |
+
response_format: Audio format for output
|
| 212 |
+
instructions: Optional instructions for voice modulation
|
| 213 |
+
max_length: Maximum length per chunk (default: 4096)
|
| 214 |
+
preserve_words: Whether to avoid splitting words (default: True)
|
| 215 |
+
**kwargs: Additional parameters
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
List[TTSResponse]: List of generated audio responses
|
| 219 |
+
|
| 220 |
+
Raises:
|
| 221 |
+
TTSException: If generation fails for any chunk
|
| 222 |
+
"""
|
| 223 |
+
|
| 224 |
+
# Sanitize text first
|
| 225 |
+
clean_text = sanitize_text(text)
|
| 226 |
+
|
| 227 |
+
# Split text into chunks
|
| 228 |
+
chunks = split_text_by_length(clean_text, max_length, preserve_words)
|
| 229 |
+
|
| 230 |
+
if not chunks:
|
| 231 |
+
raise ValueError("No valid text chunks found after processing")
|
| 232 |
+
|
| 233 |
+
responses = []
|
| 234 |
+
|
| 235 |
+
for i, chunk in enumerate(chunks):
|
| 236 |
+
logger.info(f"Processing chunk {i+1}/{len(chunks)} ({len(chunk)} characters)")
|
| 237 |
+
|
| 238 |
+
# Create request for this chunk (disable length validation since we already split)
|
| 239 |
+
request = TTSRequest(
|
| 240 |
+
input=chunk,
|
| 241 |
+
voice=voice,
|
| 242 |
+
response_format=response_format,
|
| 243 |
+
instructions=instructions,
|
| 244 |
+
max_length=max_length,
|
| 245 |
+
validate_length=False, # We already split the text
|
| 246 |
+
**kwargs
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
response = self._make_request(request)
|
| 250 |
+
responses.append(response)
|
| 251 |
+
|
| 252 |
+
return responses
|
| 253 |
+
|
| 254 |
+
def _make_request(self, request: TTSRequest) -> TTSResponse:
|
| 255 |
+
"""
|
| 256 |
+
Make the actual HTTP request to the openai.fm TTS service.
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
request: TTS request object
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
TTSResponse: Generated audio response
|
| 263 |
+
|
| 264 |
+
Raises:
|
| 265 |
+
TTSException: If request fails
|
| 266 |
+
"""
|
| 267 |
+
url = build_url(self.base_url, "api/generate")
|
| 268 |
+
|
| 269 |
+
# Prepare form data for openai.fm API
|
| 270 |
+
form_data = {
|
| 271 |
+
'input': request.input,
|
| 272 |
+
'voice': request.voice.value,
|
| 273 |
+
'generation': str(uuid.uuid4()),
|
| 274 |
+
'response_format': request.response_format.value if hasattr(request.response_format, 'value') else str(request.response_format)
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
# Add prompt/instructions if provided
|
| 278 |
+
if request.instructions:
|
| 279 |
+
form_data['prompt'] = request.instructions
|
| 280 |
+
else:
|
| 281 |
+
# Default prompt for better quality
|
| 282 |
+
form_data['prompt'] = (
|
| 283 |
+
"Affect/personality: Natural and clear\n\n"
|
| 284 |
+
"Tone: Friendly and professional, creating a pleasant listening experience.\n\n"
|
| 285 |
+
"Pronunciation: Clear, articulate, and steady, ensuring each word is easily understood "
|
| 286 |
+
"while maintaining a natural, conversational flow.\n\n"
|
| 287 |
+
"Pause: Brief, purposeful pauses between sentences to allow time for the listener "
|
| 288 |
+
"to process the information.\n\n"
|
| 289 |
+
"Emotion: Warm and engaging, conveying the intended message effectively."
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# Get optimized headers for the requested format
|
| 293 |
+
# Convert string format to AudioFormat enum if needed
|
| 294 |
+
requested_format = request.response_format
|
| 295 |
+
if isinstance(requested_format, str):
|
| 296 |
+
try:
|
| 297 |
+
requested_format = AudioFormat(requested_format.lower())
|
| 298 |
+
except ValueError:
|
| 299 |
+
requested_format = AudioFormat.WAV # Default to WAV for unknown formats
|
| 300 |
+
|
| 301 |
+
format_headers = self._get_headers_for_format(requested_format)
|
| 302 |
+
|
| 303 |
+
logger.info(f"Generating speech for text: '{request.input[:50]}...' with voice: {request.voice}")
|
| 304 |
+
logger.debug(f"Using headers optimized for {requested_format.value} format")
|
| 305 |
+
|
| 306 |
+
# Make request with retries
|
| 307 |
+
for attempt in range(self.max_retries + 1):
|
| 308 |
+
try:
|
| 309 |
+
# Add random delay for rate limiting (except first attempt)
|
| 310 |
+
if attempt > 0:
|
| 311 |
+
delay = exponential_backoff(attempt - 1)
|
| 312 |
+
logger.info(f"Retrying request after {delay:.2f}s (attempt {attempt + 1})")
|
| 313 |
+
time.sleep(delay)
|
| 314 |
+
|
| 315 |
+
# Use multipart form data as required by openai.fm
|
| 316 |
+
response = self.session.post(
|
| 317 |
+
url,
|
| 318 |
+
data=form_data,
|
| 319 |
+
headers=format_headers,
|
| 320 |
+
timeout=self.timeout,
|
| 321 |
+
verify=self.verify_ssl
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
# Handle different response types
|
| 325 |
+
if response.status_code == 200:
|
| 326 |
+
return self._process_openai_fm_response(response, request)
|
| 327 |
+
else:
|
| 328 |
+
# Try to parse error response
|
| 329 |
+
try:
|
| 330 |
+
error_data = response.json()
|
| 331 |
+
except (json.JSONDecodeError, ValueError):
|
| 332 |
+
error_data = {"error": {"message": response.text or "Unknown error"}}
|
| 333 |
+
|
| 334 |
+
# Create appropriate exception
|
| 335 |
+
exception = create_exception_from_response(
|
| 336 |
+
response.status_code,
|
| 337 |
+
error_data,
|
| 338 |
+
f"TTS request failed with status {response.status_code}"
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
# Don't retry for certain errors
|
| 342 |
+
if response.status_code in [400, 401, 403, 404]:
|
| 343 |
+
raise exception
|
| 344 |
+
|
| 345 |
+
# For retryable errors, continue to next attempt
|
| 346 |
+
if attempt == self.max_retries:
|
| 347 |
+
raise exception
|
| 348 |
+
|
| 349 |
+
logger.warning(f"Request failed with status {response.status_code}, retrying...")
|
| 350 |
+
continue
|
| 351 |
+
|
| 352 |
+
except requests.exceptions.Timeout:
|
| 353 |
+
if attempt == self.max_retries:
|
| 354 |
+
raise NetworkException(
|
| 355 |
+
f"Request timed out after {self.timeout}s",
|
| 356 |
+
timeout=self.timeout,
|
| 357 |
+
retry_count=attempt
|
| 358 |
+
)
|
| 359 |
+
logger.warning(f"Request timed out, retrying...")
|
| 360 |
+
continue
|
| 361 |
+
|
| 362 |
+
except requests.exceptions.ConnectionError as e:
|
| 363 |
+
if attempt == self.max_retries:
|
| 364 |
+
raise NetworkException(
|
| 365 |
+
f"Connection error: {str(e)}",
|
| 366 |
+
retry_count=attempt
|
| 367 |
+
)
|
| 368 |
+
logger.warning(f"Connection error, retrying...")
|
| 369 |
+
continue
|
| 370 |
+
|
| 371 |
+
except requests.exceptions.RequestException as e:
|
| 372 |
+
if attempt == self.max_retries:
|
| 373 |
+
raise NetworkException(
|
| 374 |
+
f"Request error: {str(e)}",
|
| 375 |
+
retry_count=attempt
|
| 376 |
+
)
|
| 377 |
+
logger.warning(f"Request error, retrying...")
|
| 378 |
+
continue
|
| 379 |
+
|
| 380 |
+
# This should never be reached, but just in case
|
| 381 |
+
raise TTSException("Maximum retries exceeded")
|
| 382 |
+
|
| 383 |
+
def _process_openai_fm_response(self, response: requests.Response, request: TTSRequest) -> TTSResponse:
|
| 384 |
+
"""
|
| 385 |
+
Process a successful response from the openai.fm TTS service.
|
| 386 |
+
|
| 387 |
+
Args:
|
| 388 |
+
response: HTTP response object
|
| 389 |
+
request: Original TTS request
|
| 390 |
+
|
| 391 |
+
Returns:
|
| 392 |
+
TTSResponse: Processed response object
|
| 393 |
+
"""
|
| 394 |
+
# Get content type from response headers
|
| 395 |
+
content_type = response.headers.get("content-type", "audio/mpeg")
|
| 396 |
+
|
| 397 |
+
# Get audio data
|
| 398 |
+
audio_data = response.content
|
| 399 |
+
|
| 400 |
+
if not audio_data:
|
| 401 |
+
raise APIException("Received empty audio data from openai.fm")
|
| 402 |
+
|
| 403 |
+
# Determine format from content type
|
| 404 |
+
if "audio/mpeg" in content_type or "audio/mp3" in content_type:
|
| 405 |
+
actual_format = AudioFormat.MP3
|
| 406 |
+
elif "audio/wav" in content_type:
|
| 407 |
+
actual_format = AudioFormat.WAV
|
| 408 |
+
elif "audio/opus" in content_type:
|
| 409 |
+
actual_format = AudioFormat.OPUS
|
| 410 |
+
elif "audio/aac" in content_type:
|
| 411 |
+
actual_format = AudioFormat.AAC
|
| 412 |
+
elif "audio/flac" in content_type:
|
| 413 |
+
actual_format = AudioFormat.FLAC
|
| 414 |
+
else:
|
| 415 |
+
# Default to MP3 for openai.fm
|
| 416 |
+
actual_format = AudioFormat.MP3
|
| 417 |
+
|
| 418 |
+
# Estimate duration based on text length (rough approximation)
|
| 419 |
+
estimated_duration = estimate_audio_duration(request.input)
|
| 420 |
+
|
| 421 |
+
# Check if returned format differs from requested format
|
| 422 |
+
requested_format = request.response_format
|
| 423 |
+
if isinstance(requested_format, str):
|
| 424 |
+
try:
|
| 425 |
+
requested_format = AudioFormat(requested_format.lower())
|
| 426 |
+
except ValueError:
|
| 427 |
+
requested_format = AudioFormat.WAV # Default fallback
|
| 428 |
+
|
| 429 |
+
# Import here to avoid circular imports
|
| 430 |
+
from .models import get_supported_format, maps_to_wav
|
| 431 |
+
|
| 432 |
+
# Check if format differs from request
|
| 433 |
+
if actual_format != requested_format:
|
| 434 |
+
if maps_to_wav(requested_format.value) and actual_format.value == "wav":
|
| 435 |
+
logger.debug(
|
| 436 |
+
f"Format '{requested_format.value}' requested, returning WAV format."
|
| 437 |
+
)
|
| 438 |
+
else:
|
| 439 |
+
logger.warning(
|
| 440 |
+
f"Requested format '{requested_format.value}' but received '{actual_format.value}' "
|
| 441 |
+
f"from service."
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
# Create response object
|
| 445 |
+
tts_response = TTSResponse(
|
| 446 |
+
audio_data=audio_data,
|
| 447 |
+
content_type=content_type,
|
| 448 |
+
format=actual_format,
|
| 449 |
+
size=len(audio_data),
|
| 450 |
+
duration=estimated_duration,
|
| 451 |
+
metadata={
|
| 452 |
+
"response_headers": dict(response.headers),
|
| 453 |
+
"status_code": response.status_code,
|
| 454 |
+
"url": str(response.url),
|
| 455 |
+
"service": "openai.fm",
|
| 456 |
+
"voice": request.voice.value,
|
| 457 |
+
"original_text": request.input[:100] + "..." if len(request.input) > 100 else request.input,
|
| 458 |
+
"requested_format": requested_format.value,
|
| 459 |
+
"actual_format": actual_format.value
|
| 460 |
+
}
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
logger.info(
|
| 464 |
+
f"Successfully generated {format_file_size(len(audio_data))} "
|
| 465 |
+
f"of {actual_format.value.upper()} audio from openai.fm using voice '{request.voice.value}'"
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
return tts_response
|
| 469 |
+
|
| 470 |
+
def close(self):
|
| 471 |
+
"""Close the HTTP session."""
|
| 472 |
+
if hasattr(self, 'session'):
|
| 473 |
+
self.session.close()
|
| 474 |
+
|
| 475 |
+
def __enter__(self):
|
| 476 |
+
"""Context manager entry."""
|
| 477 |
+
return self
|
| 478 |
+
|
| 479 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 480 |
+
"""Context manager exit."""
|
| 481 |
+
self.close()
|
ttsfm/exceptions.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Exception classes for the TTSFM package.
|
| 3 |
+
|
| 4 |
+
This module defines the exception hierarchy used throughout the package
|
| 5 |
+
for consistent error handling and reporting.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Optional, Dict, Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TTSException(Exception):
|
| 12 |
+
"""
|
| 13 |
+
Base exception class for all TTSFM-related errors.
|
| 14 |
+
|
| 15 |
+
Attributes:
|
| 16 |
+
message: Human-readable error message
|
| 17 |
+
code: Error code for programmatic handling
|
| 18 |
+
details: Additional error details
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
message: str,
|
| 24 |
+
code: Optional[str] = None,
|
| 25 |
+
details: Optional[Dict[str, Any]] = None
|
| 26 |
+
):
|
| 27 |
+
super().__init__(message)
|
| 28 |
+
self.message = message
|
| 29 |
+
self.code = code or self.__class__.__name__
|
| 30 |
+
self.details = details or {}
|
| 31 |
+
|
| 32 |
+
def __str__(self) -> str:
|
| 33 |
+
if self.code:
|
| 34 |
+
return f"[{self.code}] {self.message}"
|
| 35 |
+
return self.message
|
| 36 |
+
|
| 37 |
+
def __repr__(self) -> str:
|
| 38 |
+
return f"{self.__class__.__name__}(message='{self.message}', code='{self.code}')"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class APIException(TTSException):
|
| 42 |
+
"""
|
| 43 |
+
Exception raised for API-related errors.
|
| 44 |
+
|
| 45 |
+
This includes HTTP errors, invalid responses, and server-side issues.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def __init__(
|
| 49 |
+
self,
|
| 50 |
+
message: str,
|
| 51 |
+
status_code: Optional[int] = None,
|
| 52 |
+
response_data: Optional[Dict[str, Any]] = None,
|
| 53 |
+
**kwargs
|
| 54 |
+
):
|
| 55 |
+
super().__init__(message, **kwargs)
|
| 56 |
+
self.status_code = status_code
|
| 57 |
+
self.response_data = response_data or {}
|
| 58 |
+
|
| 59 |
+
def __str__(self) -> str:
|
| 60 |
+
if self.status_code:
|
| 61 |
+
return f"[HTTP {self.status_code}] {self.message}"
|
| 62 |
+
return super().__str__()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class NetworkException(TTSException):
|
| 66 |
+
"""
|
| 67 |
+
Exception raised for network-related errors.
|
| 68 |
+
|
| 69 |
+
This includes connection timeouts, DNS resolution failures, and other
|
| 70 |
+
network connectivity issues.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
message: str,
|
| 76 |
+
timeout: Optional[float] = None,
|
| 77 |
+
retry_count: int = 0,
|
| 78 |
+
**kwargs
|
| 79 |
+
):
|
| 80 |
+
super().__init__(message, **kwargs)
|
| 81 |
+
self.timeout = timeout
|
| 82 |
+
self.retry_count = retry_count
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ValidationException(TTSException):
|
| 86 |
+
"""
|
| 87 |
+
Exception raised for input validation errors.
|
| 88 |
+
|
| 89 |
+
This includes invalid parameters, missing required fields, and
|
| 90 |
+
data format issues.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
def __init__(
|
| 94 |
+
self,
|
| 95 |
+
message: str,
|
| 96 |
+
field: Optional[str] = None,
|
| 97 |
+
value: Optional[Any] = None,
|
| 98 |
+
**kwargs
|
| 99 |
+
):
|
| 100 |
+
super().__init__(message, **kwargs)
|
| 101 |
+
self.field = field
|
| 102 |
+
self.value = value
|
| 103 |
+
|
| 104 |
+
def __str__(self) -> str:
|
| 105 |
+
if self.field:
|
| 106 |
+
return f"Validation error for '{self.field}': {self.message}"
|
| 107 |
+
return f"Validation error: {self.message}"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class RateLimitException(APIException):
|
| 111 |
+
"""
|
| 112 |
+
Exception raised when API rate limits are exceeded.
|
| 113 |
+
|
| 114 |
+
Attributes:
|
| 115 |
+
retry_after: Seconds to wait before retrying (if provided by server)
|
| 116 |
+
limit: Rate limit that was exceeded
|
| 117 |
+
remaining: Remaining requests in current window
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
def __init__(
|
| 121 |
+
self,
|
| 122 |
+
message: str = "Rate limit exceeded",
|
| 123 |
+
retry_after: Optional[int] = None,
|
| 124 |
+
limit: Optional[int] = None,
|
| 125 |
+
remaining: Optional[int] = None,
|
| 126 |
+
**kwargs
|
| 127 |
+
):
|
| 128 |
+
super().__init__(message, status_code=429, **kwargs)
|
| 129 |
+
self.retry_after = retry_after
|
| 130 |
+
self.limit = limit
|
| 131 |
+
self.remaining = remaining
|
| 132 |
+
|
| 133 |
+
def __str__(self) -> str:
|
| 134 |
+
msg = super().__str__()
|
| 135 |
+
if self.retry_after:
|
| 136 |
+
msg += f" (retry after {self.retry_after}s)"
|
| 137 |
+
return msg
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class AuthenticationException(APIException):
|
| 141 |
+
"""
|
| 142 |
+
Exception raised for authentication and authorization errors.
|
| 143 |
+
|
| 144 |
+
This includes invalid API keys, expired tokens, and insufficient
|
| 145 |
+
permissions.
|
| 146 |
+
"""
|
| 147 |
+
|
| 148 |
+
def __init__(
|
| 149 |
+
self,
|
| 150 |
+
message: str = "Authentication failed",
|
| 151 |
+
**kwargs
|
| 152 |
+
):
|
| 153 |
+
super().__init__(message, status_code=401, **kwargs)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class ServiceUnavailableException(APIException):
|
| 157 |
+
"""
|
| 158 |
+
Exception raised when the TTS service is temporarily unavailable.
|
| 159 |
+
|
| 160 |
+
This includes server maintenance, overload conditions, and
|
| 161 |
+
temporary service outages.
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
def __init__(
|
| 165 |
+
self,
|
| 166 |
+
message: str = "Service temporarily unavailable",
|
| 167 |
+
retry_after: Optional[int] = None,
|
| 168 |
+
**kwargs
|
| 169 |
+
):
|
| 170 |
+
super().__init__(message, status_code=503, **kwargs)
|
| 171 |
+
self.retry_after = retry_after
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class QuotaExceededException(APIException):
|
| 175 |
+
"""
|
| 176 |
+
Exception raised when usage quotas are exceeded.
|
| 177 |
+
|
| 178 |
+
This includes monthly limits, character limits, and other
|
| 179 |
+
usage-based restrictions.
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
def __init__(
|
| 183 |
+
self,
|
| 184 |
+
message: str = "Usage quota exceeded",
|
| 185 |
+
quota_type: Optional[str] = None,
|
| 186 |
+
limit: Optional[int] = None,
|
| 187 |
+
used: Optional[int] = None,
|
| 188 |
+
**kwargs
|
| 189 |
+
):
|
| 190 |
+
super().__init__(message, status_code=402, **kwargs)
|
| 191 |
+
self.quota_type = quota_type
|
| 192 |
+
self.limit = limit
|
| 193 |
+
self.used = used
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
class AudioProcessingException(TTSException):
|
| 197 |
+
"""
|
| 198 |
+
Exception raised for audio processing errors.
|
| 199 |
+
|
| 200 |
+
This includes format conversion issues, audio generation failures,
|
| 201 |
+
and output processing problems.
|
| 202 |
+
"""
|
| 203 |
+
|
| 204 |
+
def __init__(
|
| 205 |
+
self,
|
| 206 |
+
message: str,
|
| 207 |
+
audio_format: Optional[str] = None,
|
| 208 |
+
**kwargs
|
| 209 |
+
):
|
| 210 |
+
super().__init__(message, **kwargs)
|
| 211 |
+
self.audio_format = audio_format
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def create_exception_from_response(
|
| 215 |
+
status_code: int,
|
| 216 |
+
response_data: Dict[str, Any],
|
| 217 |
+
default_message: str = "API request failed"
|
| 218 |
+
) -> APIException:
|
| 219 |
+
"""
|
| 220 |
+
Create appropriate exception from API response.
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
status_code: HTTP status code
|
| 224 |
+
response_data: Response data from API
|
| 225 |
+
default_message: Default message if none in response
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
APIException: Appropriate exception instance
|
| 229 |
+
"""
|
| 230 |
+
message = response_data.get("error", {}).get("message", default_message)
|
| 231 |
+
|
| 232 |
+
if status_code == 401:
|
| 233 |
+
return AuthenticationException(message, response_data=response_data)
|
| 234 |
+
elif status_code == 402:
|
| 235 |
+
return QuotaExceededException(message, response_data=response_data)
|
| 236 |
+
elif status_code == 429:
|
| 237 |
+
retry_after = response_data.get("retry_after")
|
| 238 |
+
return RateLimitException(message, retry_after=retry_after, response_data=response_data)
|
| 239 |
+
elif status_code == 503:
|
| 240 |
+
retry_after = response_data.get("retry_after")
|
| 241 |
+
return ServiceUnavailableException(message, retry_after=retry_after, response_data=response_data)
|
| 242 |
+
else:
|
| 243 |
+
return APIException(message, status_code=status_code, response_data=response_data)
|
ttsfm/models.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data models and types for the TTSFM package.
|
| 3 |
+
|
| 4 |
+
This module defines the core data structures used throughout the package,
|
| 5 |
+
including request/response models, enums, and error types.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Optional, Dict, Any, Union
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Voice(str, Enum):
|
| 15 |
+
"""Available voice options for TTS generation."""
|
| 16 |
+
ALLOY = "alloy"
|
| 17 |
+
ASH = "ash"
|
| 18 |
+
BALLAD = "ballad"
|
| 19 |
+
CORAL = "coral"
|
| 20 |
+
ECHO = "echo"
|
| 21 |
+
FABLE = "fable"
|
| 22 |
+
NOVA = "nova"
|
| 23 |
+
ONYX = "onyx"
|
| 24 |
+
SAGE = "sage"
|
| 25 |
+
SHIMMER = "shimmer"
|
| 26 |
+
VERSE = "verse"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class AudioFormat(str, Enum):
|
| 30 |
+
"""Supported audio output formats."""
|
| 31 |
+
MP3 = "mp3"
|
| 32 |
+
WAV = "wav"
|
| 33 |
+
OPUS = "opus"
|
| 34 |
+
AAC = "aac"
|
| 35 |
+
FLAC = "flac"
|
| 36 |
+
PCM = "pcm"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class TTSRequest:
|
| 41 |
+
"""
|
| 42 |
+
Request model for TTS generation.
|
| 43 |
+
|
| 44 |
+
Attributes:
|
| 45 |
+
input: Text to convert to speech
|
| 46 |
+
voice: Voice to use for generation
|
| 47 |
+
response_format: Audio format for output
|
| 48 |
+
instructions: Optional instructions for voice modulation
|
| 49 |
+
model: Model to use (for OpenAI compatibility, usually ignored)
|
| 50 |
+
speed: Speech speed (for OpenAI compatibility, usually ignored)
|
| 51 |
+
max_length: Maximum allowed text length (default: 4096 characters)
|
| 52 |
+
validate_length: Whether to validate text length (default: True)
|
| 53 |
+
"""
|
| 54 |
+
input: str
|
| 55 |
+
voice: Union[Voice, str] = Voice.ALLOY
|
| 56 |
+
response_format: Union[AudioFormat, str] = AudioFormat.MP3
|
| 57 |
+
instructions: Optional[str] = None
|
| 58 |
+
model: Optional[str] = None
|
| 59 |
+
speed: Optional[float] = None
|
| 60 |
+
max_length: int = 4096
|
| 61 |
+
validate_length: bool = True
|
| 62 |
+
|
| 63 |
+
def __post_init__(self):
|
| 64 |
+
"""Validate and normalize fields after initialization."""
|
| 65 |
+
# Ensure voice is a valid Voice enum
|
| 66 |
+
if isinstance(self.voice, str):
|
| 67 |
+
try:
|
| 68 |
+
self.voice = Voice(self.voice.lower())
|
| 69 |
+
except ValueError:
|
| 70 |
+
raise ValueError(f"Invalid voice: {self.voice}. Must be one of {list(Voice)}")
|
| 71 |
+
|
| 72 |
+
# Ensure response_format is a valid AudioFormat enum
|
| 73 |
+
if isinstance(self.response_format, str):
|
| 74 |
+
try:
|
| 75 |
+
self.response_format = AudioFormat(self.response_format.lower())
|
| 76 |
+
except ValueError:
|
| 77 |
+
raise ValueError(f"Invalid format: {self.response_format}. Must be one of {list(AudioFormat)}")
|
| 78 |
+
|
| 79 |
+
# Validate input text
|
| 80 |
+
if not self.input or not self.input.strip():
|
| 81 |
+
raise ValueError("Input text cannot be empty")
|
| 82 |
+
|
| 83 |
+
# Validate text length if enabled
|
| 84 |
+
if self.validate_length:
|
| 85 |
+
text_length = len(self.input)
|
| 86 |
+
if text_length > self.max_length:
|
| 87 |
+
raise ValueError(
|
| 88 |
+
f"Input text is too long ({text_length} characters). "
|
| 89 |
+
f"Maximum allowed length is {self.max_length} characters. "
|
| 90 |
+
f"Consider splitting your text into smaller chunks or disable "
|
| 91 |
+
f"length validation with validate_length=False."
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Validate max_length parameter
|
| 95 |
+
if self.max_length <= 0:
|
| 96 |
+
raise ValueError("max_length must be a positive integer")
|
| 97 |
+
|
| 98 |
+
# Validate speed if provided
|
| 99 |
+
if self.speed is not None and (self.speed < 0.25 or self.speed > 4.0):
|
| 100 |
+
raise ValueError("Speed must be between 0.25 and 4.0")
|
| 101 |
+
|
| 102 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 103 |
+
"""Convert request to dictionary for API calls."""
|
| 104 |
+
data = {
|
| 105 |
+
"input": self.input,
|
| 106 |
+
"voice": self.voice.value if isinstance(self.voice, Voice) else self.voice,
|
| 107 |
+
"response_format": self.response_format.value if isinstance(self.response_format, AudioFormat) else self.response_format
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if self.instructions:
|
| 111 |
+
data["instructions"] = self.instructions
|
| 112 |
+
|
| 113 |
+
if self.model:
|
| 114 |
+
data["model"] = self.model
|
| 115 |
+
|
| 116 |
+
if self.speed is not None:
|
| 117 |
+
data["speed"] = self.speed
|
| 118 |
+
|
| 119 |
+
return data
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@dataclass
|
| 123 |
+
class TTSResponse:
|
| 124 |
+
"""
|
| 125 |
+
Response model for TTS generation.
|
| 126 |
+
|
| 127 |
+
Attributes:
|
| 128 |
+
audio_data: Generated audio as bytes
|
| 129 |
+
content_type: MIME type of the audio data
|
| 130 |
+
format: Audio format used
|
| 131 |
+
size: Size of audio data in bytes
|
| 132 |
+
duration: Estimated duration in seconds (if available)
|
| 133 |
+
metadata: Additional response metadata
|
| 134 |
+
"""
|
| 135 |
+
audio_data: bytes
|
| 136 |
+
content_type: str
|
| 137 |
+
format: AudioFormat
|
| 138 |
+
size: int
|
| 139 |
+
duration: Optional[float] = None
|
| 140 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 141 |
+
|
| 142 |
+
def __post_init__(self):
|
| 143 |
+
"""Calculate derived fields after initialization."""
|
| 144 |
+
if self.size is None:
|
| 145 |
+
self.size = len(self.audio_data)
|
| 146 |
+
|
| 147 |
+
def save_to_file(self, filename: str) -> str:
|
| 148 |
+
"""
|
| 149 |
+
Save audio data to a file.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
filename: Target filename (extension will be added if missing)
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
str: Final filename used
|
| 156 |
+
"""
|
| 157 |
+
import os
|
| 158 |
+
|
| 159 |
+
# Use the actual returned format for the extension, not any requested format
|
| 160 |
+
expected_extension = f".{self.format.value}"
|
| 161 |
+
|
| 162 |
+
# Check if filename already has the correct extension
|
| 163 |
+
if filename.endswith(expected_extension):
|
| 164 |
+
final_filename = filename
|
| 165 |
+
else:
|
| 166 |
+
# Remove any existing extension and add the correct one
|
| 167 |
+
base_name = filename
|
| 168 |
+
# Remove common audio extensions if present
|
| 169 |
+
for ext in ['.mp3', '.wav', '.opus', '.aac', '.flac', '.pcm']:
|
| 170 |
+
if base_name.endswith(ext):
|
| 171 |
+
base_name = base_name[:-len(ext)]
|
| 172 |
+
break
|
| 173 |
+
final_filename = f"{base_name}{expected_extension}"
|
| 174 |
+
|
| 175 |
+
# Create directory if it doesn't exist
|
| 176 |
+
os.makedirs(os.path.dirname(final_filename) if os.path.dirname(final_filename) else ".", exist_ok=True)
|
| 177 |
+
|
| 178 |
+
# Write audio data
|
| 179 |
+
with open(final_filename, "wb") as f:
|
| 180 |
+
f.write(self.audio_data)
|
| 181 |
+
|
| 182 |
+
return final_filename
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@dataclass
|
| 186 |
+
class TTSError:
|
| 187 |
+
"""
|
| 188 |
+
Error information from TTS API.
|
| 189 |
+
|
| 190 |
+
Attributes:
|
| 191 |
+
code: Error code
|
| 192 |
+
message: Human-readable error message
|
| 193 |
+
type: Error type/category
|
| 194 |
+
details: Additional error details
|
| 195 |
+
timestamp: When the error occurred
|
| 196 |
+
"""
|
| 197 |
+
code: str
|
| 198 |
+
message: str
|
| 199 |
+
type: Optional[str] = None
|
| 200 |
+
details: Optional[Dict[str, Any]] = None
|
| 201 |
+
timestamp: Optional[datetime] = None
|
| 202 |
+
|
| 203 |
+
def __post_init__(self):
|
| 204 |
+
"""Set timestamp if not provided."""
|
| 205 |
+
if self.timestamp is None:
|
| 206 |
+
self.timestamp = datetime.now()
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@dataclass
|
| 210 |
+
class APIError(TTSError):
|
| 211 |
+
"""API-specific error information."""
|
| 212 |
+
status_code: int = 500
|
| 213 |
+
headers: Optional[Dict[str, str]] = None
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@dataclass
|
| 217 |
+
class NetworkError(TTSError):
|
| 218 |
+
"""Network-related error information."""
|
| 219 |
+
timeout: Optional[float] = None
|
| 220 |
+
retry_count: int = 0
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@dataclass
|
| 224 |
+
class ValidationError(TTSError):
|
| 225 |
+
"""Validation error information."""
|
| 226 |
+
field: Optional[str] = None
|
| 227 |
+
value: Optional[Any] = None
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# Content type mappings for audio formats
|
| 231 |
+
CONTENT_TYPE_MAP = {
|
| 232 |
+
AudioFormat.MP3: "audio/mpeg",
|
| 233 |
+
AudioFormat.OPUS: "audio/opus",
|
| 234 |
+
AudioFormat.AAC: "audio/aac",
|
| 235 |
+
AudioFormat.FLAC: "audio/flac",
|
| 236 |
+
AudioFormat.WAV: "audio/wav",
|
| 237 |
+
AudioFormat.PCM: "audio/pcm"
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
# Reverse mapping for content type to format
|
| 241 |
+
FORMAT_FROM_CONTENT_TYPE = {v: k for k, v in CONTENT_TYPE_MAP.items()}
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def get_content_type(format: Union[AudioFormat, str]) -> str:
|
| 245 |
+
"""Get MIME content type for audio format."""
|
| 246 |
+
if isinstance(format, str):
|
| 247 |
+
format = AudioFormat(format.lower())
|
| 248 |
+
return CONTENT_TYPE_MAP.get(format, "audio/mpeg")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def get_format_from_content_type(content_type: str) -> AudioFormat:
|
| 252 |
+
"""Get audio format from MIME content type."""
|
| 253 |
+
return FORMAT_FROM_CONTENT_TYPE.get(content_type, AudioFormat.MP3)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def get_supported_format(requested_format: AudioFormat) -> AudioFormat:
|
| 257 |
+
"""
|
| 258 |
+
Map requested format to supported format.
|
| 259 |
+
|
| 260 |
+
Args:
|
| 261 |
+
requested_format: The requested audio format
|
| 262 |
+
|
| 263 |
+
Returns:
|
| 264 |
+
AudioFormat: MP3 or WAV (the supported formats)
|
| 265 |
+
"""
|
| 266 |
+
if requested_format == AudioFormat.MP3:
|
| 267 |
+
return AudioFormat.MP3
|
| 268 |
+
else:
|
| 269 |
+
# All other formats (WAV, OPUS, AAC, FLAC, PCM) return WAV
|
| 270 |
+
return AudioFormat.WAV
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def maps_to_wav(format_value: str) -> bool:
|
| 274 |
+
"""
|
| 275 |
+
Check if a format maps to WAV.
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
format_value: Format string to check
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
bool: True if the format maps to WAV
|
| 282 |
+
"""
|
| 283 |
+
return format_value.lower() in ['wav', 'opus', 'aac', 'flac', 'pcm']
|
ttsfm/utils.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility functions for the TTSFM package.
|
| 3 |
+
|
| 4 |
+
This module provides common utility functions used throughout the package,
|
| 5 |
+
including HTTP helpers, validation utilities, and configuration management.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import time
|
| 11 |
+
import random
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Dict, Any, Optional, Union, List
|
| 14 |
+
from urllib.parse import urljoin, urlparse
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Configure logging
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_user_agent() -> str:
|
| 22 |
+
"""
|
| 23 |
+
Generate a realistic User-Agent string.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
str: User-Agent string for HTTP requests
|
| 27 |
+
"""
|
| 28 |
+
try:
|
| 29 |
+
from fake_useragent import UserAgent
|
| 30 |
+
ua = UserAgent()
|
| 31 |
+
return ua.random
|
| 32 |
+
except ImportError:
|
| 33 |
+
# Fallback if fake_useragent is not available
|
| 34 |
+
return "TTSFM-Client/3.0.0 (Python)"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_realistic_headers() -> Dict[str, str]:
|
| 38 |
+
"""
|
| 39 |
+
Generate realistic HTTP headers for requests.
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Dict[str, str]: HTTP headers dictionary
|
| 43 |
+
"""
|
| 44 |
+
user_agent = get_user_agent()
|
| 45 |
+
|
| 46 |
+
headers = {
|
| 47 |
+
"Accept": "application/json, audio/*",
|
| 48 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 49 |
+
"Accept-Language": random.choice(["en-US,en;q=0.9", "en-GB,en;q=0.8", "en-CA,en;q=0.7"]),
|
| 50 |
+
"Cache-Control": "no-cache",
|
| 51 |
+
"DNT": "1",
|
| 52 |
+
"Pragma": "no-cache",
|
| 53 |
+
"User-Agent": user_agent,
|
| 54 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# Add browser-specific headers for Chromium-based browsers
|
| 58 |
+
if any(browser in user_agent.lower() for browser in ['chrome', 'edge', 'chromium']):
|
| 59 |
+
version_match = re.search(r'(?:Chrome|Edge|Chromium)/(\d+)', user_agent)
|
| 60 |
+
major_version = version_match.group(1) if version_match else "121"
|
| 61 |
+
|
| 62 |
+
brands = []
|
| 63 |
+
if 'google chrome' in user_agent.lower():
|
| 64 |
+
brands.extend([
|
| 65 |
+
f'"Google Chrome";v="{major_version}"',
|
| 66 |
+
f'"Chromium";v="{major_version}"',
|
| 67 |
+
'"Not A(Brand";v="99"'
|
| 68 |
+
])
|
| 69 |
+
elif 'microsoft edge' in user_agent.lower():
|
| 70 |
+
brands.extend([
|
| 71 |
+
f'"Microsoft Edge";v="{major_version}"',
|
| 72 |
+
f'"Chromium";v="{major_version}"',
|
| 73 |
+
'"Not A(Brand";v="99"'
|
| 74 |
+
])
|
| 75 |
+
else:
|
| 76 |
+
brands.extend([
|
| 77 |
+
f'"Chromium";v="{major_version}"',
|
| 78 |
+
'"Not A(Brand";v="8"'
|
| 79 |
+
])
|
| 80 |
+
|
| 81 |
+
headers.update({
|
| 82 |
+
"Sec-Ch-Ua": ", ".join(brands),
|
| 83 |
+
"Sec-Ch-Ua-Mobile": "?0",
|
| 84 |
+
"Sec-Ch-Ua-Platform": random.choice(['"Windows"', '"macOS"', '"Linux"']),
|
| 85 |
+
"Sec-Fetch-Dest": "empty",
|
| 86 |
+
"Sec-Fetch-Mode": "cors",
|
| 87 |
+
"Sec-Fetch-Site": "same-origin"
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
# Randomly add some optional headers
|
| 91 |
+
if random.random() < 0.5:
|
| 92 |
+
headers["Upgrade-Insecure-Requests"] = "1"
|
| 93 |
+
|
| 94 |
+
return headers
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def validate_text_length(text: str, max_length: int = 4096, raise_error: bool = True) -> bool:
|
| 98 |
+
"""
|
| 99 |
+
Validate text length against maximum allowed characters.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
text: Text to validate
|
| 103 |
+
max_length: Maximum allowed length in characters
|
| 104 |
+
raise_error: Whether to raise an exception if validation fails
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
bool: True if text is within limits, False otherwise
|
| 108 |
+
|
| 109 |
+
Raises:
|
| 110 |
+
ValueError: If text exceeds max_length and raise_error is True
|
| 111 |
+
"""
|
| 112 |
+
if not text:
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
text_length = len(text)
|
| 116 |
+
|
| 117 |
+
if text_length > max_length:
|
| 118 |
+
if raise_error:
|
| 119 |
+
raise ValueError(
|
| 120 |
+
f"Text is too long ({text_length} characters). "
|
| 121 |
+
f"Maximum allowed length is {max_length} characters. "
|
| 122 |
+
f"TTS models typically support up to 4096 characters per request."
|
| 123 |
+
)
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
return True
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def split_text_by_length(text: str, max_length: int = 4096, preserve_words: bool = True) -> List[str]:
|
| 130 |
+
"""
|
| 131 |
+
Split text into chunks that don't exceed the maximum length.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
text: Text to split
|
| 135 |
+
max_length: Maximum length per chunk
|
| 136 |
+
preserve_words: Whether to avoid splitting words
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
List[str]: List of text chunks
|
| 140 |
+
"""
|
| 141 |
+
if not text:
|
| 142 |
+
return []
|
| 143 |
+
|
| 144 |
+
if len(text) <= max_length:
|
| 145 |
+
return [text]
|
| 146 |
+
|
| 147 |
+
chunks = []
|
| 148 |
+
|
| 149 |
+
if preserve_words:
|
| 150 |
+
# Split by sentences first, then by words if needed
|
| 151 |
+
sentences = re.split(r'[.!?]+', text)
|
| 152 |
+
current_chunk = ""
|
| 153 |
+
|
| 154 |
+
for sentence in sentences:
|
| 155 |
+
sentence = sentence.strip()
|
| 156 |
+
if not sentence:
|
| 157 |
+
continue
|
| 158 |
+
|
| 159 |
+
# Add sentence ending punctuation back
|
| 160 |
+
if not sentence.endswith(('.', '!', '?')):
|
| 161 |
+
sentence += '.'
|
| 162 |
+
|
| 163 |
+
# Check if adding this sentence would exceed the limit
|
| 164 |
+
test_chunk = current_chunk + (" " if current_chunk else "") + sentence
|
| 165 |
+
|
| 166 |
+
if len(test_chunk) <= max_length:
|
| 167 |
+
current_chunk = test_chunk
|
| 168 |
+
else:
|
| 169 |
+
# Save current chunk if it has content
|
| 170 |
+
if current_chunk:
|
| 171 |
+
chunks.append(current_chunk.strip())
|
| 172 |
+
|
| 173 |
+
# If single sentence is too long, split by words
|
| 174 |
+
if len(sentence) > max_length:
|
| 175 |
+
word_chunks = _split_by_words(sentence, max_length)
|
| 176 |
+
chunks.extend(word_chunks)
|
| 177 |
+
current_chunk = ""
|
| 178 |
+
else:
|
| 179 |
+
current_chunk = sentence
|
| 180 |
+
|
| 181 |
+
# Add remaining chunk
|
| 182 |
+
if current_chunk:
|
| 183 |
+
chunks.append(current_chunk.strip())
|
| 184 |
+
else:
|
| 185 |
+
# Simple character-based splitting
|
| 186 |
+
for i in range(0, len(text), max_length):
|
| 187 |
+
chunks.append(text[i:i + max_length])
|
| 188 |
+
|
| 189 |
+
return [chunk for chunk in chunks if chunk.strip()]
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _split_by_words(text: str, max_length: int) -> List[str]:
|
| 193 |
+
"""
|
| 194 |
+
Split text by words when sentences are too long.
|
| 195 |
+
|
| 196 |
+
Args:
|
| 197 |
+
text: Text to split
|
| 198 |
+
max_length: Maximum length per chunk
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
List[str]: List of word-based chunks
|
| 202 |
+
"""
|
| 203 |
+
words = text.split()
|
| 204 |
+
chunks = []
|
| 205 |
+
current_chunk = ""
|
| 206 |
+
|
| 207 |
+
for word in words:
|
| 208 |
+
test_chunk = current_chunk + (" " if current_chunk else "") + word
|
| 209 |
+
|
| 210 |
+
if len(test_chunk) <= max_length:
|
| 211 |
+
current_chunk = test_chunk
|
| 212 |
+
else:
|
| 213 |
+
if current_chunk:
|
| 214 |
+
chunks.append(current_chunk)
|
| 215 |
+
|
| 216 |
+
# If single word is too long, split it
|
| 217 |
+
if len(word) > max_length:
|
| 218 |
+
for i in range(0, len(word), max_length):
|
| 219 |
+
chunks.append(word[i:i + max_length])
|
| 220 |
+
current_chunk = ""
|
| 221 |
+
else:
|
| 222 |
+
current_chunk = word
|
| 223 |
+
|
| 224 |
+
if current_chunk:
|
| 225 |
+
chunks.append(current_chunk)
|
| 226 |
+
|
| 227 |
+
return chunks
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def sanitize_text(text: str) -> str:
|
| 231 |
+
"""
|
| 232 |
+
Sanitize input text for TTS processing.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
text: Input text to sanitize
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
str: Sanitized text
|
| 239 |
+
"""
|
| 240 |
+
if not text:
|
| 241 |
+
return ""
|
| 242 |
+
|
| 243 |
+
# Remove HTML tags
|
| 244 |
+
text = re.sub(r'<[^>]+>', '', text)
|
| 245 |
+
|
| 246 |
+
# Remove script tags and content
|
| 247 |
+
text = re.sub(r'<script.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
| 248 |
+
|
| 249 |
+
# Remove potentially dangerous characters
|
| 250 |
+
text = re.sub(r'[<>"\']', '', text)
|
| 251 |
+
|
| 252 |
+
# Normalize whitespace
|
| 253 |
+
text = re.sub(r'\s+', ' ', text)
|
| 254 |
+
|
| 255 |
+
return text.strip()
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def validate_url(url: str) -> bool:
|
| 259 |
+
"""
|
| 260 |
+
Validate if a URL is properly formatted.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
url: URL to validate
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
bool: True if URL is valid, False otherwise
|
| 267 |
+
"""
|
| 268 |
+
try:
|
| 269 |
+
result = urlparse(url)
|
| 270 |
+
return all([result.scheme, result.netloc])
|
| 271 |
+
except Exception:
|
| 272 |
+
return False
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def build_url(base_url: str, path: str) -> str:
|
| 276 |
+
"""
|
| 277 |
+
Build a complete URL from base URL and path.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
base_url: Base URL
|
| 281 |
+
path: Path to append
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
str: Complete URL
|
| 285 |
+
"""
|
| 286 |
+
# Ensure base_url ends with /
|
| 287 |
+
if not base_url.endswith('/'):
|
| 288 |
+
base_url += '/'
|
| 289 |
+
|
| 290 |
+
# Ensure path doesn't start with /
|
| 291 |
+
if path.startswith('/'):
|
| 292 |
+
path = path[1:]
|
| 293 |
+
|
| 294 |
+
return urljoin(base_url, path)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def get_random_delay(min_delay: float = 1.0, max_delay: float = 5.0) -> float:
|
| 298 |
+
"""
|
| 299 |
+
Get a random delay with jitter for rate limiting.
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
min_delay: Minimum delay in seconds
|
| 303 |
+
max_delay: Maximum delay in seconds
|
| 304 |
+
|
| 305 |
+
Returns:
|
| 306 |
+
float: Random delay in seconds
|
| 307 |
+
"""
|
| 308 |
+
base_delay = random.uniform(min_delay, max_delay)
|
| 309 |
+
jitter = random.uniform(0.1, 0.5)
|
| 310 |
+
return base_delay + jitter
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def exponential_backoff(attempt: int, base_delay: float = 1.0, max_delay: float = 60.0) -> float:
|
| 314 |
+
"""
|
| 315 |
+
Calculate exponential backoff delay.
|
| 316 |
+
|
| 317 |
+
Args:
|
| 318 |
+
attempt: Attempt number (0-based)
|
| 319 |
+
base_delay: Base delay in seconds
|
| 320 |
+
max_delay: Maximum delay in seconds
|
| 321 |
+
|
| 322 |
+
Returns:
|
| 323 |
+
float: Delay in seconds
|
| 324 |
+
"""
|
| 325 |
+
delay = base_delay * (2 ** attempt)
|
| 326 |
+
jitter = random.uniform(0.1, 0.3) * delay
|
| 327 |
+
return min(delay + jitter, max_delay)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def load_config_from_env(prefix: str = "TTSFM_") -> Dict[str, Any]:
|
| 331 |
+
"""
|
| 332 |
+
Load configuration from environment variables.
|
| 333 |
+
|
| 334 |
+
Args:
|
| 335 |
+
prefix: Prefix for environment variables
|
| 336 |
+
|
| 337 |
+
Returns:
|
| 338 |
+
Dict[str, Any]: Configuration dictionary
|
| 339 |
+
"""
|
| 340 |
+
config = {}
|
| 341 |
+
|
| 342 |
+
for key, value in os.environ.items():
|
| 343 |
+
if key.startswith(prefix):
|
| 344 |
+
config_key = key[len(prefix):].lower()
|
| 345 |
+
|
| 346 |
+
# Try to convert to appropriate type
|
| 347 |
+
if value.lower() in ('true', 'false'):
|
| 348 |
+
config[config_key] = value.lower() == 'true'
|
| 349 |
+
elif value.isdigit():
|
| 350 |
+
config[config_key] = int(value)
|
| 351 |
+
elif '.' in value and value.replace('.', '').isdigit():
|
| 352 |
+
config[config_key] = float(value)
|
| 353 |
+
else:
|
| 354 |
+
config[config_key] = value
|
| 355 |
+
|
| 356 |
+
return config
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def setup_logging(level: Union[str, int] = logging.INFO, format_string: Optional[str] = None) -> None:
|
| 360 |
+
"""
|
| 361 |
+
Setup logging configuration for the package.
|
| 362 |
+
|
| 363 |
+
Args:
|
| 364 |
+
level: Logging level
|
| 365 |
+
format_string: Custom format string
|
| 366 |
+
"""
|
| 367 |
+
if format_string is None:
|
| 368 |
+
format_string = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 369 |
+
|
| 370 |
+
logging.basicConfig(
|
| 371 |
+
level=level,
|
| 372 |
+
format=format_string,
|
| 373 |
+
handlers=[logging.StreamHandler()]
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def estimate_audio_duration(text: str, words_per_minute: float = 150.0) -> float:
|
| 378 |
+
"""
|
| 379 |
+
Estimate audio duration based on text length.
|
| 380 |
+
|
| 381 |
+
Args:
|
| 382 |
+
text: Input text
|
| 383 |
+
words_per_minute: Average speaking rate
|
| 384 |
+
|
| 385 |
+
Returns:
|
| 386 |
+
float: Estimated duration in seconds
|
| 387 |
+
"""
|
| 388 |
+
if not text:
|
| 389 |
+
return 0.0
|
| 390 |
+
|
| 391 |
+
# Count words (simple whitespace split)
|
| 392 |
+
word_count = len(text.split())
|
| 393 |
+
|
| 394 |
+
# Calculate duration in seconds
|
| 395 |
+
duration = (word_count / words_per_minute) * 60.0
|
| 396 |
+
|
| 397 |
+
# Add some buffer for pauses and processing
|
| 398 |
+
return duration * 1.1
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def format_file_size(size_bytes: int) -> str:
|
| 402 |
+
"""
|
| 403 |
+
Format file size in human-readable format.
|
| 404 |
+
|
| 405 |
+
Args:
|
| 406 |
+
size_bytes: Size in bytes
|
| 407 |
+
|
| 408 |
+
Returns:
|
| 409 |
+
str: Formatted size string
|
| 410 |
+
"""
|
| 411 |
+
if size_bytes == 0:
|
| 412 |
+
return "0 B"
|
| 413 |
+
|
| 414 |
+
size_names = ["B", "KB", "MB", "GB"]
|
| 415 |
+
i = 0
|
| 416 |
+
|
| 417 |
+
while size_bytes >= 1024 and i < len(size_names) - 1:
|
| 418 |
+
size_bytes /= 1024.0
|
| 419 |
+
i += 1
|
| 420 |
+
|
| 421 |
+
return f"{size_bytes:.1f} {size_names[i]}"
|