Spaces:
Sleeping
Sleeping
Commit ยท
493b3af
0
Parent(s):
Initial commit: FastAPI TTS Project ready for Vast.ai
Browse files- .dockerignore +16 -0
- .gitignore +23 -0
- Dockerfile +30 -0
- README.md +104 -0
- app.py +128 -0
- requirements.txt +8 -0
- utils/audio_utils.py +71 -0
- utils/config.py +41 -0
- utils/text_utils.py +98 -0
.dockerignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
.Python
|
| 6 |
+
env/
|
| 7 |
+
venv/
|
| 8 |
+
.env
|
| 9 |
+
.venv
|
| 10 |
+
outputs/
|
| 11 |
+
temp/
|
| 12 |
+
models/
|
| 13 |
+
.ipynb_checkpoints/
|
| 14 |
+
*.ipynb
|
| 15 |
+
.git/
|
| 16 |
+
.gitignore
|
.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# Virtual environments
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
.venv/
|
| 10 |
+
.env/
|
| 11 |
+
|
| 12 |
+
# Project specific
|
| 13 |
+
outputs/
|
| 14 |
+
temp/
|
| 15 |
+
models/
|
| 16 |
+
|
| 17 |
+
# Jupyter Notebook
|
| 18 |
+
.ipynb_checkpoints
|
| 19 |
+
*.ipynb
|
| 20 |
+
|
| 21 |
+
# OS generated files
|
| 22 |
+
.DS_Store
|
| 23 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official NVIDIA CUDA base image with Ubuntu 22.04 to ensure Vast.ai compatibility and GPU support
|
| 2 |
+
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04
|
| 3 |
+
|
| 4 |
+
# Set non-interactive to avoid prompts during apt-get
|
| 5 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 6 |
+
|
| 7 |
+
# Install Python 3.10, pip, and ffmpeg
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
python3 \
|
| 10 |
+
python3-pip \
|
| 11 |
+
ffmpeg \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# Set the working directory
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
# Copy the requirements file into the container
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
|
| 20 |
+
# Install dependencies (including torch for potential GPU inference)
|
| 21 |
+
RUN pip3 install --no-cache-dir -r requirements.txt
|
| 22 |
+
|
| 23 |
+
# Copy the rest of the application code
|
| 24 |
+
COPY . .
|
| 25 |
+
|
| 26 |
+
# Expose the FastAPI port
|
| 27 |
+
EXPOSE 8000
|
| 28 |
+
|
| 29 |
+
# Run the FastAPI application using uvicorn
|
| 30 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Text-to-Speech (TTS) FastAPI Service
|
| 2 |
+
|
| 3 |
+
This is a production-ready Text-to-Speech API built with FastAPI and `edge-tts`. It is optimized for asynchronous generation, handles long texts by chunking them, and is structured for deployment on GPU-enabled platforms like Vast.ai.
|
| 4 |
+
|
| 5 |
+
## Project Structure
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
project/
|
| 9 |
+
โโโ app.py # FastAPI application
|
| 10 |
+
โโโ requirements.txt # Dependencies
|
| 11 |
+
โโโ Dockerfile # Docker configuration
|
| 12 |
+
โโโ .dockerignore # Docker ignore file
|
| 13 |
+
โโโ models/ # Directory for local models (if added later)
|
| 14 |
+
โโโ outputs/ # Temporary output directory for generated audio
|
| 15 |
+
โโโ temp/ # Temporary chunk processing directory
|
| 16 |
+
โโโ utils/ # Utility modules
|
| 17 |
+
โ โโโ config.py # Configuration and settings
|
| 18 |
+
โ โโโ text_utils.py # Text cleaning and chunking logic
|
| 19 |
+
โ โโโ audio_utils.py # TTS generation and audio concatenation logic
|
| 20 |
+
โโโ README.md # Documentation
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## Features
|
| 24 |
+
- **FastAPI / Uvicorn**: High-performance asynchronous API.
|
| 25 |
+
- **Robust Chunking**: Automatically chunks long text inputs without breaking sentences.
|
| 26 |
+
- **Edge-TTS Integration**: Uses Microsoft Edge's neural TTS service.
|
| 27 |
+
- **GPU Readiness**: Includes `torch` and memory cleanup (`torch.cuda.empty_cache()`) for compatibility with Vast.ai templates, allowing easy drop-in of local GPU models later.
|
| 28 |
+
- **Temp File Cleanup**: Automatically cleans up temporary chunks and output files after they are served.
|
| 29 |
+
|
| 30 |
+
## How to Run Locally
|
| 31 |
+
|
| 32 |
+
### Prerequisites
|
| 33 |
+
- Python 3.10+
|
| 34 |
+
- `ffmpeg` installed on your system.
|
| 35 |
+
|
| 36 |
+
### Steps
|
| 37 |
+
1. Install dependencies:
|
| 38 |
+
```bash
|
| 39 |
+
pip install -r requirements.txt
|
| 40 |
+
```
|
| 41 |
+
2. Run the application:
|
| 42 |
+
```bash
|
| 43 |
+
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
|
| 44 |
+
```
|
| 45 |
+
3. Access the API documentation at `http://localhost:8000/docs`.
|
| 46 |
+
|
| 47 |
+
## How to Build and Run the Docker Image
|
| 48 |
+
|
| 49 |
+
1. Build the Docker image:
|
| 50 |
+
```bash
|
| 51 |
+
docker build -t tts-fastapi .
|
| 52 |
+
```
|
| 53 |
+
2. Run the Docker container:
|
| 54 |
+
```bash
|
| 55 |
+
docker run -p 8000:8000 --gpus all tts-fastapi
|
| 56 |
+
```
|
| 57 |
+
*(Note: `--gpus all` is optional if you are strictly using `edge-tts` but recommended if you are deploying to a GPU-enabled instance and plan to use local PyTorch models).*
|
| 58 |
+
|
| 59 |
+
## How to Deploy on Vast.ai
|
| 60 |
+
|
| 61 |
+
1. Spin up an instance on Vast.ai.
|
| 62 |
+
2. Under "Template Configuration", select a base image such as `nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04` or simply check the box for "Use Custom Image" and enter `python:3.10` or a custom Docker Hub image if you have pushed yours.
|
| 63 |
+
3. If using an unconfigured instance, SSH in and run:
|
| 64 |
+
```bash
|
| 65 |
+
git clone <your-repo-url>
|
| 66 |
+
cd tts-repo
|
| 67 |
+
docker build -t tts-fastapi .
|
| 68 |
+
docker run -d -p 8000:8000 --gpus all tts-fastapi
|
| 69 |
+
```
|
| 70 |
+
4. Ensure port `8000` is mapped and exposed in your Vast.ai instance settings so you can reach the API externally.
|
| 71 |
+
|
| 72 |
+
## API Usage
|
| 73 |
+
|
| 74 |
+
### `POST /generate`
|
| 75 |
+
|
| 76 |
+
**Endpoint**: `/generate`
|
| 77 |
+
**Content-Type**: `application/json`
|
| 78 |
+
|
| 79 |
+
**Request Body**:
|
| 80 |
+
```json
|
| 81 |
+
{
|
| 82 |
+
"text": "Hello! This is a test of the Text to Speech API. It can handle very long text by splitting it appropriately.",
|
| 83 |
+
"lang": "en",
|
| 84 |
+
"voice_id": "preset_1"
|
| 85 |
+
}
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
**Parameters**:
|
| 89 |
+
- `text` (string, required): The text to synthesize.
|
| 90 |
+
- `lang` (string, optional): Language code (e.g., `"en"`, `"ar"`). Defaults to `"en"`.
|
| 91 |
+
- `voice_id` (string, optional): A preset voice from the registry (`preset_1` to `preset_5`). Defaults to `"preset_1"`.
|
| 92 |
+
- `custom_voice_name` (string, optional): A specific Edge-TTS voice name (e.g., `"en-US-AriaNeural"`).
|
| 93 |
+
|
| 94 |
+
**Response**:
|
| 95 |
+
Returns the generated audio file (`audio/wav`).
|
| 96 |
+
|
| 97 |
+
### Example using `curl`
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
curl -X POST "http://localhost:8000/generate" \
|
| 101 |
+
-H "Content-Type: application/json" \
|
| 102 |
+
-d '{"text":"Welcome to the text to speech service.","voice_id":"preset_2"}' \
|
| 103 |
+
--output generated_speech.wav
|
| 104 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
| 2 |
+
from fastapi.responses import FileResponse
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
import uuid
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import asyncio
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# GPU Optimization Imports (as per user request)
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
from utils.config import (
|
| 15 |
+
DIR_TEMP, DIR_OUTPUT, DEFAULT_LANG, DEFAULT_VOICE_ID,
|
| 16 |
+
resolve_voice_name, MAX_CHARS, MIN_CHARS
|
| 17 |
+
)
|
| 18 |
+
from utils.text_utils import chunk_text
|
| 19 |
+
from utils.audio_utils import tts_to_wav, concat_wavs_by_timeline
|
| 20 |
+
|
| 21 |
+
app = FastAPI(
|
| 22 |
+
title="TTS FastAPI Service",
|
| 23 |
+
description="Production-ready Text-to-Speech API using Edge-TTS",
|
| 24 |
+
version="1.0.0"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Startup event to clear temp and output directories and initialize GPU
|
| 28 |
+
@app.on_event("startup")
|
| 29 |
+
async def startup_event():
|
| 30 |
+
# Cleanup previous runs
|
| 31 |
+
for directory in [DIR_TEMP, DIR_OUTPUT]:
|
| 32 |
+
for item in directory.glob("*"):
|
| 33 |
+
if item.is_file():
|
| 34 |
+
item.unlink()
|
| 35 |
+
elif item.is_dir():
|
| 36 |
+
shutil.rmtree(item)
|
| 37 |
+
|
| 38 |
+
# Optional GPU Warmup/Initialization
|
| 39 |
+
if torch.cuda.is_available():
|
| 40 |
+
print(f"CUDA is available: {torch.cuda.get_device_name(0)}")
|
| 41 |
+
torch.cuda.empty_cache()
|
| 42 |
+
else:
|
| 43 |
+
print("CUDA is not available. Running on CPU (edge-tts is API based).")
|
| 44 |
+
|
| 45 |
+
class TTSRequest(BaseModel):
|
| 46 |
+
text: str = Field(..., description="Text to synthesize into speech")
|
| 47 |
+
lang: str = Field(DEFAULT_LANG, description="Language code (e.g., 'en', 'ar')")
|
| 48 |
+
voice_id: str = Field(DEFAULT_VOICE_ID, description="Voice ID from registry")
|
| 49 |
+
custom_voice_name: Optional[str] = Field(None, description="Direct Voice name, e.g. 'en-US-GuyNeural'")
|
| 50 |
+
|
| 51 |
+
def remove_file(path: Path):
|
| 52 |
+
"""Background task to remove a file after returning it."""
|
| 53 |
+
try:
|
| 54 |
+
if path.exists():
|
| 55 |
+
path.unlink()
|
| 56 |
+
except Exception as e:
|
| 57 |
+
print(f"Failed to delete {path}: {e}")
|
| 58 |
+
|
| 59 |
+
@app.post("/generate", summary="Generate TTS Audio from Text")
|
| 60 |
+
async def generate_audio(request: TTSRequest, background_tasks: BackgroundTasks):
|
| 61 |
+
if not request.text.strip():
|
| 62 |
+
raise HTTPException(status_code=400, detail="Text cannot be empty.")
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
voice_name = resolve_voice_name(request.voice_id, request.custom_voice_name)
|
| 66 |
+
except ValueError as e:
|
| 67 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 68 |
+
|
| 69 |
+
req_id = uuid.uuid4().hex[:8]
|
| 70 |
+
req_temp_dir = DIR_TEMP / req_id
|
| 71 |
+
req_temp_dir.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
|
| 73 |
+
output_wav = DIR_OUTPUT / f"output_{req_id}.wav"
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
# Wrap inference simulation in no_grad for GPU memory optimization requirements
|
| 77 |
+
with torch.no_grad():
|
| 78 |
+
# 1. Chunk Text
|
| 79 |
+
chunks = chunk_text(request.text, lang=request.lang, max_chars=MAX_CHARS, min_chars=MIN_CHARS)
|
| 80 |
+
|
| 81 |
+
if not chunks:
|
| 82 |
+
raise HTTPException(status_code=400, detail="No valid text found after cleaning.")
|
| 83 |
+
|
| 84 |
+
# 2. Generate Audio for each chunk asynchronously
|
| 85 |
+
tasks = []
|
| 86 |
+
chunk_wav_paths = []
|
| 87 |
+
|
| 88 |
+
for i, chunk in enumerate(chunks):
|
| 89 |
+
chunk_wav = req_temp_dir / f"chunk_{i:04d}.wav"
|
| 90 |
+
chunk_wav_paths.append(chunk_wav)
|
| 91 |
+
tasks.append(tts_to_wav(chunk, chunk_wav, voice_name))
|
| 92 |
+
|
| 93 |
+
# Run all edge-tts requests concurrently
|
| 94 |
+
await asyncio.gather(*tasks)
|
| 95 |
+
|
| 96 |
+
# 3. Concatenate
|
| 97 |
+
if len(chunk_wav_paths) == 1:
|
| 98 |
+
# If only one chunk, just move it to output
|
| 99 |
+
shutil.move(str(chunk_wav_paths[0]), str(output_wav))
|
| 100 |
+
else:
|
| 101 |
+
await concat_wavs_by_timeline(chunk_wav_paths, output_wav)
|
| 102 |
+
|
| 103 |
+
# Ensure CUDA cache is cleared after generation to prevent memory leaks
|
| 104 |
+
if torch.cuda.is_available():
|
| 105 |
+
torch.cuda.empty_cache()
|
| 106 |
+
|
| 107 |
+
# Schedule cleanup of the output file after it's returned
|
| 108 |
+
background_tasks.add_task(remove_file, output_wav)
|
| 109 |
+
|
| 110 |
+
# Cleanup the temp directory for this request
|
| 111 |
+
shutil.rmtree(req_temp_dir, ignore_errors=True)
|
| 112 |
+
|
| 113 |
+
return FileResponse(
|
| 114 |
+
path=output_wav,
|
| 115 |
+
media_type="audio/wav",
|
| 116 |
+
filename="generated_speech.wav"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
# Cleanup on failure
|
| 121 |
+
shutil.rmtree(req_temp_dir, ignore_errors=True)
|
| 122 |
+
if output_wav.exists():
|
| 123 |
+
output_wav.unlink()
|
| 124 |
+
|
| 125 |
+
if torch.cuda.is_available():
|
| 126 |
+
torch.cuda.empty_cache()
|
| 127 |
+
|
| 128 |
+
raise HTTPException(status_code=500, detail=str(e))
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn==0.24.0
|
| 3 |
+
edge-tts==6.1.9
|
| 4 |
+
nest_asyncio==1.5.8
|
| 5 |
+
tqdm==4.66.1
|
| 6 |
+
unidecode==1.3.7
|
| 7 |
+
torch==2.1.0 # Added for GPU support and optimization readiness
|
| 8 |
+
python-multipart==0.0.6
|
utils/audio_utils.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import edge_tts
|
| 3 |
+
import subprocess
|
| 4 |
+
import wave
|
| 5 |
+
import contextlib
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from utils.config import EDGE_RATE, EDGE_PITCH, SAMPLE_RATE
|
| 8 |
+
|
| 9 |
+
def wav_duration_seconds(path: Path) -> float:
|
| 10 |
+
with contextlib.closing(wave.open(str(path), "rb")) as wf:
|
| 11 |
+
return wf.getnframes() / float(wf.getframerate())
|
| 12 |
+
|
| 13 |
+
async def _edge_save_mp3(text: str, voice_name: str, out_mp3: Path, rate: str, pitch: str):
|
| 14 |
+
comm = edge_tts.Communicate(text=text, voice=voice_name, rate=rate, pitch=pitch)
|
| 15 |
+
await comm.save(str(out_mp3))
|
| 16 |
+
|
| 17 |
+
async def tts_to_wav(text: str, out_wav: Path, voice_name: str) -> None:
|
| 18 |
+
"""
|
| 19 |
+
Generates consistent PCM WAV (mono, SAMPLE_RATE) for easy concatenation.
|
| 20 |
+
"""
|
| 21 |
+
out_wav.parent.mkdir(parents=True, exist_ok=True)
|
| 22 |
+
tmp_mp3 = out_wav.with_suffix(".tmp.mp3")
|
| 23 |
+
|
| 24 |
+
await _edge_save_mp3(text, voice_name, tmp_mp3, EDGE_RATE, EDGE_PITCH)
|
| 25 |
+
|
| 26 |
+
# Convert mp3 to wav using ffmpeg
|
| 27 |
+
process = await asyncio.create_subprocess_exec(
|
| 28 |
+
"ffmpeg", "-y", "-i", str(tmp_mp3),
|
| 29 |
+
"-ac", "1", "-ar", str(SAMPLE_RATE), "-c:a", "pcm_s16le",
|
| 30 |
+
str(out_wav),
|
| 31 |
+
stdout=subprocess.PIPE,
|
| 32 |
+
stderr=subprocess.PIPE
|
| 33 |
+
)
|
| 34 |
+
stdout, stderr = await process.communicate()
|
| 35 |
+
|
| 36 |
+
if process.returncode != 0:
|
| 37 |
+
raise RuntimeError(f"FFmpeg failed with error:\\n{stderr.decode()}")
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
tmp_mp3.unlink()
|
| 41 |
+
except Exception:
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
async def concat_wavs_by_timeline(wav_paths: list[Path], out_wav: Path):
|
| 45 |
+
"""
|
| 46 |
+
Concatenates multiple WAV files into a single WAV file.
|
| 47 |
+
"""
|
| 48 |
+
if not wav_paths:
|
| 49 |
+
raise ValueError("No WAV files to concatenate.")
|
| 50 |
+
|
| 51 |
+
concat_file = out_wav.with_suffix(".concat.txt")
|
| 52 |
+
with concat_file.open("w", encoding="utf-8") as f:
|
| 53 |
+
for p in wav_paths:
|
| 54 |
+
# ffmpeg concat demuxer requires absolute paths or relative to the list file
|
| 55 |
+
f.write(f"file '{str(p.resolve())}'\\n")
|
| 56 |
+
|
| 57 |
+
process = await asyncio.create_subprocess_exec(
|
| 58 |
+
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file),
|
| 59 |
+
"-ac", "1", "-ar", str(SAMPLE_RATE), "-c:a", "pcm_s16le", str(out_wav),
|
| 60 |
+
stdout=subprocess.PIPE,
|
| 61 |
+
stderr=subprocess.PIPE
|
| 62 |
+
)
|
| 63 |
+
stdout, stderr = await process.communicate()
|
| 64 |
+
|
| 65 |
+
if process.returncode != 0:
|
| 66 |
+
raise RuntimeError(f"FFmpeg concatenation failed:\\n{stderr.decode()}")
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
concat_file.unlink()
|
| 70 |
+
except Exception:
|
| 71 |
+
pass
|
utils/config.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
# ---------- Base Directories ----------
|
| 5 |
+
BASE_DIR = Path(__file__).parent.parent
|
| 6 |
+
DIR_TEMP = BASE_DIR / "temp"
|
| 7 |
+
DIR_OUTPUT = BASE_DIR / "outputs"
|
| 8 |
+
DIR_MODELS = BASE_DIR / "models"
|
| 9 |
+
|
| 10 |
+
# Ensure directories exist
|
| 11 |
+
for d in [DIR_TEMP, DIR_OUTPUT, DIR_MODELS]:
|
| 12 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 13 |
+
|
| 14 |
+
# ---------- TTS (Edge-TTS) Configuration ----------
|
| 15 |
+
DEFAULT_VOICE_ID = "preset_1"
|
| 16 |
+
CUSTOM_VOICE_NAME = None # e.g. "en-US-GuyNeural"
|
| 17 |
+
EDGE_RATE = "+0%"
|
| 18 |
+
EDGE_PITCH = "+0Hz"
|
| 19 |
+
|
| 20 |
+
VOICE_REGISTRY = {
|
| 21 |
+
"preset_1": "en-US-GuyNeural",
|
| 22 |
+
"preset_2": "en-US-JennyNeural",
|
| 23 |
+
"preset_3": "en-GB-RyanNeural",
|
| 24 |
+
"preset_4": "en-GB-SoniaNeural",
|
| 25 |
+
"preset_5": "ar-EG-SalmaNeural",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
# ---------- Text + Chunking ----------
|
| 29 |
+
DEFAULT_LANG = "en"
|
| 30 |
+
MAX_CHARS = 220
|
| 31 |
+
MIN_CHARS = 20
|
| 32 |
+
|
| 33 |
+
# ---------- Audio Formats ----------
|
| 34 |
+
SAMPLE_RATE = 22050 # Output sample rate for consistent mono PCM wav
|
| 35 |
+
|
| 36 |
+
def resolve_voice_name(voice_id: str, custom_name: str = None) -> str:
|
| 37 |
+
if custom_name and str(custom_name).strip():
|
| 38 |
+
return str(custom_name).strip()
|
| 39 |
+
if voice_id not in VOICE_REGISTRY:
|
| 40 |
+
raise ValueError(f"Unknown VOICE_ID={voice_id}. Use {list(VOICE_REGISTRY.keys())} or set CUSTOM_VOICE_NAME.")
|
| 41 |
+
return VOICE_REGISTRY[voice_id]
|
utils/text_utils.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import List
|
| 3 |
+
|
| 4 |
+
_AR_DIACRITICS = re.compile(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED]")
|
| 5 |
+
_SENT_SPLIT = re.compile(r"(?<=[\\.\\!\\?])\s+|(?<=[\u061F\u06D4])\s+|(?<=\n)\s*")
|
| 6 |
+
_SOFT_SPLIT = re.compile(r"(?<=[,;:ุุ])\s+")
|
| 7 |
+
|
| 8 |
+
def normalize_whitespace(s: str) -> str:
|
| 9 |
+
s = s.replace("\u00A0", " ")
|
| 10 |
+
s = re.sub(r"[ \t]+", " ", s)
|
| 11 |
+
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 12 |
+
return s.strip()
|
| 13 |
+
|
| 14 |
+
def normalize_punctuation(s: str) -> str:
|
| 15 |
+
s = s.replace("โฆ", "...")
|
| 16 |
+
s = s.replace("โ", '"').replace("โ", '"').replace("โ", "'").replace("โ", "'")
|
| 17 |
+
s = s.replace("โ", "-").replace("โ", "-")
|
| 18 |
+
s = re.sub(r"\s+([,.;:!?])", r"\1", s)
|
| 19 |
+
s = re.sub(r"([,.;:!?])([^\s])", r"\1 \2", s)
|
| 20 |
+
return s
|
| 21 |
+
|
| 22 |
+
def normalize_arabic(s: str) -> str:
|
| 23 |
+
s = s.replace("\u0640", "") # tatweel
|
| 24 |
+
s = _AR_DIACRITICS.sub("", s) # diacritics
|
| 25 |
+
s = s.replace("ุ", "ุ ").replace("ุ", "ุ ").replace("ุ", "ุ ")
|
| 26 |
+
s = re.sub(r"\s+", " ", s)
|
| 27 |
+
return s.strip()
|
| 28 |
+
|
| 29 |
+
def clean_text(s: str, lang: str = "en") -> str:
|
| 30 |
+
s = normalize_whitespace(s)
|
| 31 |
+
s = normalize_punctuation(s)
|
| 32 |
+
if lang.lower().startswith("ar"):
|
| 33 |
+
s = normalize_arabic(s)
|
| 34 |
+
s = re.sub(r"[\u200B-\u200F\u202A-\u202E]", "", s) # bidi junk
|
| 35 |
+
return normalize_whitespace(s)
|
| 36 |
+
|
| 37 |
+
def split_into_sentences(text: str) -> List[str]:
|
| 38 |
+
text = normalize_whitespace(text)
|
| 39 |
+
return [p.strip() for p in _SENT_SPLIT.split(text) if p.strip()]
|
| 40 |
+
|
| 41 |
+
def hard_wrap_by_words(s: str, max_chars: int) -> List[str]:
|
| 42 |
+
words = s.split()
|
| 43 |
+
out, cur = [], []
|
| 44 |
+
for w in words:
|
| 45 |
+
cand = (" ".join(cur + [w])).strip()
|
| 46 |
+
if len(cand) <= max_chars:
|
| 47 |
+
cur.append(w)
|
| 48 |
+
else:
|
| 49 |
+
if cur:
|
| 50 |
+
out.append(" ".join(cur))
|
| 51 |
+
cur = [w]
|
| 52 |
+
else:
|
| 53 |
+
out.append(w[:max_chars])
|
| 54 |
+
rest = w[max_chars:]
|
| 55 |
+
if rest:
|
| 56 |
+
cur = [rest]
|
| 57 |
+
if cur:
|
| 58 |
+
out.append(" ".join(cur))
|
| 59 |
+
return [x.strip() for x in out if x.strip()]
|
| 60 |
+
|
| 61 |
+
def chunk_text(text: str, lang: str = "en", max_chars: int = 220, min_chars: int = 20) -> List[str]:
|
| 62 |
+
text = clean_text(text, lang=lang)
|
| 63 |
+
sents = split_into_sentences(text)
|
| 64 |
+
|
| 65 |
+
chunks, cur = [], ""
|
| 66 |
+
|
| 67 |
+
def flush():
|
| 68 |
+
nonlocal cur
|
| 69 |
+
if cur.strip():
|
| 70 |
+
chunks.append(cur.strip())
|
| 71 |
+
cur = ""
|
| 72 |
+
|
| 73 |
+
for sent in sents:
|
| 74 |
+
if len(sent) > max_chars:
|
| 75 |
+
subs = [x.strip() for x in _SOFT_SPLIT.split(sent) if x.strip()]
|
| 76 |
+
if len(subs) == 1:
|
| 77 |
+
subs = hard_wrap_by_words(sent, max_chars=max_chars)
|
| 78 |
+
else:
|
| 79 |
+
subs = [sent]
|
| 80 |
+
|
| 81 |
+
for part in subs:
|
| 82 |
+
if not cur:
|
| 83 |
+
cur = part
|
| 84 |
+
elif len(cur) + 1 + len(part) <= max_chars:
|
| 85 |
+
cur = cur + " " + part
|
| 86 |
+
else:
|
| 87 |
+
flush()
|
| 88 |
+
cur = part
|
| 89 |
+
|
| 90 |
+
flush()
|
| 91 |
+
|
| 92 |
+
merged = []
|
| 93 |
+
for ch in chunks:
|
| 94 |
+
if merged and len(ch) < min_chars:
|
| 95 |
+
merged[-1] = (merged[-1] + " " + ch).strip()
|
| 96 |
+
else:
|
| 97 |
+
merged.append(ch)
|
| 98 |
+
return merged
|