diff --git a/.opencode/init b/.opencode/init new file mode 100644 index 0000000..e69de29 diff --git a/.opencode/opencode.db b/.opencode/opencode.db new file mode 100644 index 0000000..1de43ed Binary files /dev/null and b/.opencode/opencode.db differ diff --git a/.opencode/opencode.db-shm b/.opencode/opencode.db-shm new file mode 100644 index 0000000..021f41b Binary files /dev/null and b/.opencode/opencode.db-shm differ diff --git a/.opencode/opencode.db-wal b/.opencode/opencode.db-wal new file mode 100644 index 0000000..49ea358 Binary files /dev/null and b/.opencode/opencode.db-wal differ diff --git a/AGENTS.md b/AGENTS.md index 3af786c..9698e0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ python backend/run_api_test.py # Run all backend tests # Frontend npm test # Run all frontend tests npx jest frontend/tests/api.test.js # Run single test file + +# Alternative UI +python gradio_app.py # Run Gradio interface ``` ## Code Style Guidelines @@ -38,4 +41,10 @@ npx jest frontend/tests/api.test.js # Run single test file ### Naming Conventions - Python: snake_case for variables/functions, PascalCase for classes - JavaScript: camelCase for variables/functions -- Descriptive, intention-revealing names \ No newline at end of file +- Descriptive, intention-revealing names + +### Architecture Notes +- Backend: FastAPI on port 8000, structured as routers/models/services +- Frontend: Vanilla JS (ES6+) on port 8001, modular design +- API Base URL: http://localhost:8000/api +- Speaker data in YAML format at speaker_data/speakers.yaml \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e639148..fee7ba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,11 +20,40 @@ python backend/run_api_test.py # API docs at http://127.0.0.1:8000/docs ``` -### Frontend Testing +### Frontend Development ```bash +# Install frontend dependencies +npm install + # Run frontend tests npm test + +# Start frontend dev server separately +cd frontend && python start_dev_server.py +``` + +### Integrated Development Environment + +```bash +# Start both backend and frontend servers concurrently +python start_servers.py + +# Or alternatively, run backend startup script from backend directory +cd backend && python start_server.py +``` + +### Command-Line TTS Generation + +```bash +# Generate single utterance with CLI +python cbx-generate.py --sample speaker_samples/voice.wav --output output.wav --text "Hello world" + +# Generate dialog from script +python cbx-dialog-generate.py --dialog dialog.md --output dialog_output + +# Generate audiobook from text file +python cbx-audiobook.py --input book.txt --output audiobook --speaker speaker_name ``` ### Alternative Interfaces @@ -100,3 +129,37 @@ Located in `backend/app/services/`: - Backend commands run from project root, not `backend/` directory - Frontend served separately (typically port 8001) - Speaker samples must be WAV format in `speaker_data/speaker_samples/` + +## Environment Configuration + +### Quick Setup +```bash +# Run automated setup (creates .env files) +python setup.py + +# Install dependencies +pip install -r backend/requirements.txt +npm install +``` + +### Manual Environment Variables +Key environment variables that can be configured in `.env` files: + +- `PROJECT_ROOT`: Base project directory +- `BACKEND_PORT`/`FRONTEND_PORT`: Server ports (default: 8000/8001) +- `DEVICE`: TTS model device (`auto`, `cpu`, `cuda`, `mps`) +- `CORS_ORIGINS`: Allowed frontend origins for CORS +- `SPEAKER_SAMPLES_DIR`: Directory for speaker audio files + +### Configuration Files Structure +- `.env`: Global configuration +- `backend/.env`: Backend-specific settings +- `frontend/.env`: Frontend-specific settings +- `speaker_data/speakers.yaml`: Speaker configuration + +## CLI Tools Overview + +- `cbx-generate.py`: Single utterance generation +- `cbx-dialog-generate.py`: Multi-speaker dialog generation +- `cbx-audiobook.py`: Long-form audiobook generation +- `start_servers.py`: Integrated development server launcher diff --git a/OpenCode.md b/OpenCode.md new file mode 100644 index 0000000..a6edbf1 --- /dev/null +++ b/OpenCode.md @@ -0,0 +1,62 @@ +# OpenCode.md - Chatterbox UI Development Guide + +## Build & Run Commands +```bash +# Backend (FastAPI) +pip install -r backend/requirements.txt +uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 + +# Frontend +python frontend/start_dev_server.py # Serves on port 8001 + +# Run backend tests +python backend/run_api_test.py + +# Run frontend tests +npm test + +# Run specific frontend test +npx jest frontend/tests/api.test.js + +# Run Gradio interface +python gradio_app.py + +# Run utility scripts +python cbx-audiobook.py --list-speakers # List available speakers +python cbx-audiobook.py sample-audiobook.txt --speaker # Generate audiobook +python cbx-dialog-generate.py sample-dialog.md # Generate dialog +``` + +## Code Style Guidelines + +### Python +- Use type hints (from typing import Optional, List, Dict, etc.) +- Error handling: Use try/except with specific exceptions +- Async/await for I/O operations +- Docstrings for functions and classes +- PEP 8 naming: snake_case for functions/variables, PascalCase for classes + +### JavaScript +- ES6 modules with import/export +- Async/await for API calls +- JSDoc comments for functions +- Error handling: try/catch with detailed error messages +- Camel case for variables/functions (camelCase) + +## Import Structure +- When importing from scripts, use `import import_helper` first to fix Python path +- Backend modules use relative imports within the app package +- Services are in `backend.app.services` +- Models are in `backend.app.models` +- Configuration is in `backend.app.config` + +## Project Structure +- Backend: FastAPI with service-oriented architecture +- Frontend: Vanilla JS with modular design (api.js, app.js, config.js) +- Speaker data in YAML format with WAV samples +- Output directories: dialog_output/, single_output/, tts_outputs/ + +## Common Issues +- Import errors: Make sure to use `import import_helper` in scripts +- Speaker samples must be WAV format in `speaker_data/speaker_samples/` +- TTS model requires GPU (CUDA) or Apple Silicon (MPS) \ No newline at end of file diff --git a/README.md b/README.md index 6860fed..3f863d5 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,153 @@ -# Chatterbox TTS Gradio App +# Chatterbox TTS Application -This Gradio application provides a user interface for text-to-speech generation using the Chatterbox TTS model. It supports both single utterance generation and multi-speaker dialog generation with configurable silence gaps. +A comprehensive text-to-speech application with multiple interfaces for generating speech from text using the Chatterbox TTS model. Supports single utterance generation, multi-speaker dialogs, and long-form audiobook generation. ## Features +- **Multiple Interfaces**: Web UI, FastAPI backend, Gradio interface, and CLI tools - **Single Utterance Generation**: Generate speech from text using a selected speaker - **Dialog Generation**: Create multi-speaker conversations with configurable silence gaps +- **Audiobook Generation**: Convert long-form text into narrated audiobooks - **Speaker Management**: Add/remove speakers with custom audio samples - **Memory Optimization**: Automatic model cleanup after generation -- **Output Organization**: Files saved in `single_output/` and `dialog_output/` directories +- **Output Organization**: Files saved in organized directories with ZIP packaging ## Getting Started -1. Clone the repository: +### Quick Setup + +1. Clone the repository and install dependencies: ```bash - git clone https://github.com/your-username/chatterbox-test.git + git clone https://github.com/your-username/chatterbox-ui.git + cd chatterbox-ui + pip install -r requirements.txt + npm install ``` -2. Install dependencies: +2. Run automated setup: ```bash - pip install -r requirements.txt + python setup.py ``` 3. Prepare speaker samples: - - Create a `speaker_samples/` directory - - Add audio samples (WAV format) for each speaker - - Update `speakers.yaml` with speaker names and file paths + - Add audio samples (WAV format) to `speaker_data/speaker_samples/` + - Configure speakers in `speaker_data/speakers.yaml` -4. Run the app: - ```bash - python gradio_app.py - ``` +### Running the Application + +**Full-Stack Web Application:** +```bash +# Start both backend and frontend servers +python start_servers.py +``` + +**Individual Components:** +```bash +# Backend only (FastAPI) +uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 + +# Frontend only +cd frontend && python start_dev_server.py + +# Gradio interface +python gradio_app.py +``` ## Usage -### Single Utterance Tab -- Select a speaker from the dropdown -- Enter text to synthesize -- Adjust generation parameters as needed -- Click "Generate Speech" +### Web Interface +Access the modern web UI at `http://localhost:8001` for interactive dialog creation with drag-and-drop editing. -### Dialog Generation Tab -1. Add speakers using the speaker configuration section -2. Enter dialog in the format: - ``` - Speaker1: "Hello, how are you?" - Speaker2: "I'm doing well!" - Silence: 0.5 - Speaker1: "What are your plans for today?" - ``` -3. Set output base name -4. Click "Generate Dialog" +### CLI Tools -## File Organization +**Single utterance generation:** +```bash +python cbx-generate.py --sample speaker_samples/voice.wav --output output.wav --text "Hello world" +``` -- Generated single utterances are saved to `single_output/` -- Dialog generation files are saved to `dialog_output/` -- Concatenated dialog files have `_concatenated.wav` suffix -- All files are zipped together for download +**Dialog generation:** +```bash +python cbx-dialog-generate.py --dialog dialog.md --output dialog_output +``` + +**Audiobook generation:** +```bash +python cbx-audiobook.py --input book.txt --output audiobook --speaker speaker_name +``` + +### Gradio Interface +- **Single Utterance Tab**: Select speaker, enter text, adjust parameters, generate +- **Dialog Generation Tab**: Configure speakers and create multi-speaker conversations +- Dialog format: + ``` + Speaker1: "Hello, how are you?" + Speaker2: "I'm doing well!" + Silence: 0.5 + Speaker1: "What are your plans for today?" + ``` + +## Architecture Overview + +### Application Structure +- **Frontend**: Modern vanilla JavaScript web UI (`frontend/`) +- **Backend**: FastAPI REST API (`backend/`) +- **CLI Tools**: Command-line utilities (`cbx-*.py`) +- **Gradio Interface**: Alternative web UI (`gradio_app.py`) + +### New Files and Features +- **`cbx-audiobook.py`**: Generate long-form audiobooks from text files +- **`import_helper.py`**: Utility for managing imports and dependencies +- **Backend Services**: Enhanced dialog processing, speaker management, and TTS services +- **Web Frontend**: Interactive dialog editor with drag-and-drop functionality + +### File Organization +- `single_output/` - Single utterance generations +- `dialog_output/` - Multi-speaker dialog files +- `tts_outputs/` - Raw TTS generation files +- `speaker_data/` - Speaker configurations and audio samples +- Generated files packaged in ZIP archives for download + +### API Endpoints +- `/api/speakers/` - Speaker CRUD operations +- `/api/dialog/generate/` - Full dialog generation +- `/api/dialog/generate_line/` - Single line generation +- `/generated_audio/` - Static audio file serving + +## Configuration + +### Environment Setup +Key configuration files: +- `.env` - Global settings +- `backend/.env` - Backend-specific settings +- `frontend/.env` - Frontend-specific settings +- `speaker_data/speakers.yaml` - Speaker configuration + +### Development Commands +```bash +# Run tests +python backend/run_api_test.py +npm test + +# Backend development +uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 + +# Access points +# Web UI: http://localhost:8001 +# API: http://localhost:8000 +# API Docs: http://localhost:8000/docs +``` ## Memory Management -The app automatically: +The application automatically: - Cleans up the TTS model after each generation -- Frees GPU memory (for CUDA/MPS devices) -- Deletes intermediate tensors to minimize memory footprint +- Manages GPU memory (CUDA/MPS devices) +- Optimizes memory usage for long-form content ## Troubleshooting -- **"Skipping unknown speaker"**: Add the speaker first using the speaker configuration -- **"Sample file not found"**: Verify the audio file exists in `speaker_samples/` -- **Memory issues**: Try enabling "Re-initialize model each line" for long dialogs +- **"Skipping unknown speaker"**: Configure speaker in `speaker_data/speakers.yaml` +- **"Sample file not found"**: Verify audio files exist in `speaker_data/speaker_samples/` +- **Memory issues**: Use model reinitialization options for long content +- **CORS errors**: Check frontend/backend port configuration +- **Import errors**: Run `python import_helper.py` to check dependencies diff --git a/backend/app/services/dialog_processor_service.py b/backend/app/services/dialog_processor_service.py index 833c199..df69aae 100644 --- a/backend/app/services/dialog_processor_service.py +++ b/backend/app/services/dialog_processor_service.py @@ -4,7 +4,11 @@ import re from .tts_service import TTSService from .speaker_service import SpeakerManagementService -from app import config +try: + from app import config +except ModuleNotFoundError: + # When imported from scripts at project root + from backend.app import config # Potentially models for dialog structure if we define them # from ..models.dialog_models import DialogItem # Example diff --git a/backend/app/services/speaker_service.py b/backend/app/services/speaker_service.py index b72dc6a..2e17ea8 100644 --- a/backend/app/services/speaker_service.py +++ b/backend/app/services/speaker_service.py @@ -7,8 +7,13 @@ from pathlib import Path from typing import List, Dict, Optional, Any from fastapi import UploadFile, HTTPException -from app.models.speaker_models import Speaker, SpeakerCreate -from app import config +try: + from app.models.speaker_models import Speaker, SpeakerCreate + from app import config +except ModuleNotFoundError: + # When imported from scripts at project root + from backend.app.models.speaker_models import Speaker, SpeakerCreate + from backend.app import config class SpeakerManagementService: def __init__(self): diff --git a/backend/app/services/tts_service.py b/backend/app/services/tts_service.py index 2a34469..2b3f05d 100644 --- a/backend/app/services/tts_service.py +++ b/backend/app/services/tts_service.py @@ -8,7 +8,11 @@ import os from contextlib import contextmanager # Import configuration -from app.config import TTS_TEMP_OUTPUT_DIR, SPEAKER_SAMPLES_DIR +try: + from app.config import TTS_TEMP_OUTPUT_DIR, SPEAKER_SAMPLES_DIR +except ModuleNotFoundError: + # When imported from scripts at project root + from backend.app.config import TTS_TEMP_OUTPUT_DIR, SPEAKER_SAMPLES_DIR # Use configuration for TTS output directory TTS_OUTPUT_DIR = TTS_TEMP_OUTPUT_DIR @@ -88,6 +92,7 @@ class TTSService: exaggeration: float = 0.5, # Default from Gradio cfg_weight: float = 0.5, # Default from Gradio temperature: float = 0.8, # Default from Gradio + unload_after: bool = False, # Whether to unload the model after generation ) -> Path: """ Generates speech from text using the loaded TTS model and a speaker sample. @@ -110,6 +115,7 @@ class TTSService: output_file_path = target_output_dir / f"{output_filename_base}.wav" print(f"Generating audio for text: \"{text[:50]}...\" with speaker sample: {speaker_sample_path}") + wav = None try: with torch.no_grad(): # Important for inference wav = self.model.generate( @@ -127,8 +133,22 @@ class TTSService: print(f"Error during TTS generation or saving: {e}") raise finally: - # For now, we keep it loaded. Memory management might need refinement. - pass + # Explicitly delete the wav tensor to free memory + if wav is not None: + del wav + + # Force garbage collection and cache cleanup + gc.collect() + if self.device == "cuda": + torch.cuda.empty_cache() + elif self.device == "mps": + if hasattr(torch.mps, "empty_cache"): + torch.mps.empty_cache() + + # Unload the model if requested + if unload_after: + print("Unloading TTS model after generation...") + self.unload_model() # Example usage (for testing, not part of the service itself) if __name__ == "__main__": diff --git a/cbx-audiobook.py b/cbx-audiobook.py new file mode 100755 index 0000000..94c95c3 --- /dev/null +++ b/cbx-audiobook.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python +""" +Chatterbox Audiobook Generator + +This script converts a text file into an audiobook using the Chatterbox TTS system. +It parses the text file into manageable chunks, generates audio for each chunk, +and assembles them into a complete audiobook. +""" + +import argparse +import asyncio +import gc +import os +import re +import subprocess +import sys +import torch +from pathlib import Path +import uuid + +# Import helper to fix Python path +import import_helper + +# Import backend services +from backend.app.services.tts_service import TTSService +from backend.app.services.speaker_service import SpeakerManagementService +from backend.app.services.audio_manipulation_service import AudioManipulationService +from backend.app.config import DIALOG_GENERATED_DIR, TTS_TEMP_OUTPUT_DIR + +class AudiobookGenerator: + def __init__(self, speaker_id, output_base_name, device="mps", + exaggeration=0.5, cfg_weight=0.5, temperature=0.8, + pause_between_sentences=0.5, pause_between_paragraphs=1.0, + keep_model_loaded=False, cleanup_interval=10, use_subprocess=False): + """ + Initialize the audiobook generator. + + Args: + speaker_id: ID of the speaker to use + output_base_name: Base name for output files + device: Device to use for TTS (mps, cuda, cpu) + exaggeration: Controls expressiveness (0.0-1.0) + cfg_weight: Controls alignment with speaker characteristics (0.0-1.0) + temperature: Controls randomness in generation (0.0-1.0) + pause_between_sentences: Pause duration between sentences in seconds + pause_between_paragraphs: Pause duration between paragraphs in seconds + keep_model_loaded: If True, keeps model loaded across chunks (more efficient but uses more memory) + cleanup_interval: How often to perform deep cleanup when keep_model_loaded=True + use_subprocess: If True, uses separate processes for each chunk (slower but guarantees memory release) + """ + self.speaker_id = speaker_id + self.output_base_name = output_base_name + self.device = device + self.exaggeration = exaggeration + self.cfg_weight = cfg_weight + self.temperature = temperature + self.pause_between_sentences = pause_between_sentences + self.pause_between_paragraphs = pause_between_paragraphs + self.keep_model_loaded = keep_model_loaded + self.cleanup_interval = cleanup_interval + self.use_subprocess = use_subprocess + self.chunk_counter = 0 + + # Initialize services + self.tts_service = TTSService(device=device) + self.speaker_service = SpeakerManagementService() + self.audio_manipulator = AudioManipulationService() + + # Create output directories + self.output_dir = DIALOG_GENERATED_DIR / output_base_name + self.output_dir.mkdir(parents=True, exist_ok=True) + self.temp_dir = TTS_TEMP_OUTPUT_DIR / output_base_name + self.temp_dir.mkdir(parents=True, exist_ok=True) + + # Validate speaker + self._validate_speaker() + + def _validate_speaker(self): + """Validate that the specified speaker exists.""" + speaker_info = self.speaker_service.get_speaker_by_id(self.speaker_id) + if not speaker_info: + raise ValueError(f"Speaker ID '{self.speaker_id}' not found.") + if not speaker_info.sample_path: + raise ValueError(f"Speaker ID '{self.speaker_id}' has no sample path defined.") + + # Store speaker info for later use + self.speaker_info = speaker_info + + def _cleanup_memory(self): + """Force memory cleanup and garbage collection.""" + print("Performing memory cleanup...") + + # Force garbage collection multiple times for thorough cleanup + for _ in range(3): + gc.collect() + + # Clear device-specific caches + if self.device == "cuda" and torch.cuda.is_available(): + torch.cuda.empty_cache() + torch.cuda.synchronize() + # Additional CUDA cleanup + try: + torch.cuda.reset_peak_memory_stats() + except: + pass + elif self.device == "mps" and torch.backends.mps.is_available(): + if hasattr(torch.mps, "empty_cache"): + torch.mps.empty_cache() + if hasattr(torch.mps, "synchronize"): + torch.mps.synchronize() + # Try to free MPS memory more aggressively + try: + import os + # This forces MPS to release memory back to the system + if hasattr(torch.mps, "set_per_process_memory_fraction"): + current_allocated = torch.mps.current_allocated_memory() if hasattr(torch.mps, "current_allocated_memory") else 0 + if current_allocated > 0: + torch.mps.empty_cache() + except: + pass + + # Additional aggressive cleanup + if hasattr(torch, '_C') and hasattr(torch._C, '_cuda_clearCublasWorkspaces'): + try: + torch._C._cuda_clearCublasWorkspaces() + except: + pass + + print("Memory cleanup completed.") + + async def _generate_chunk_subprocess(self, chunk, segment_filename_base, speaker_sample_path): + """ + Generate a single chunk using cbx-generate.py in a subprocess. + This guarantees memory is released when the process exits. + """ + output_file = self.temp_dir / f"{segment_filename_base}.wav" + + # Use cbx-generate.py for single chunk generation + cmd = [ + sys.executable, "cbx-generate.py", + "--sample", str(speaker_sample_path), + "--output", str(output_file), + "--text", chunk, + "--device", self.device + ] + + print(f"Running subprocess: {' '.join(cmd[:4])} ... (text truncated)") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout per chunk + cwd=Path(__file__).parent # Run from project root + ) + + if result.returncode != 0: + raise RuntimeError(f"Subprocess failed: {result.stderr}") + + if not output_file.exists(): + raise RuntimeError(f"Output file not created: {output_file}") + + print(f"Subprocess completed successfully: {output_file}") + return output_file + + except subprocess.TimeoutExpired: + raise RuntimeError(f"Subprocess timed out after 5 minutes") + except Exception as e: + raise RuntimeError(f"Subprocess error: {e}") + + def split_text_into_chunks(self, text, max_length=300): + """ + Split text into chunks suitable for TTS processing. + + This uses the same logic as the DialogProcessorService._split_text method + but adds additional paragraph handling. + """ + # Split text into paragraphs first + paragraphs = re.split(r'\n\s*\n', text) + paragraphs = [p.strip() for p in paragraphs if p.strip()] + + all_chunks = [] + + for paragraph in paragraphs: + # Split paragraph into sentences + sentences = re.split(r'(?<=[.!?\u2026])\s+|(?<=[.!?\u2026])(?=[\"\')\]\}\u201d\u2019])|(?<=[.!?\u2026])$', paragraph.strip()) + sentences = [s.strip() for s in sentences if s and s.strip()] + + chunks = [] + current_chunk = "" + + for sentence in sentences: + if not sentence: + continue + if not current_chunk: # First sentence for this chunk + current_chunk = sentence + elif len(current_chunk) + len(sentence) + 1 <= max_length: + current_chunk += " " + sentence + else: + chunks.append(current_chunk) + current_chunk = sentence + + if current_chunk: # Add the last chunk + chunks.append(current_chunk) + + # Further split any chunks that are still too long + paragraph_chunks = [] + for chunk in chunks: + if len(chunk) > max_length: + # Simple split by length if a sentence itself is too long + for i in range(0, len(chunk), max_length): + paragraph_chunks.append(chunk[i:i+max_length]) + else: + paragraph_chunks.append(chunk) + + # Add paragraph marker + if paragraph_chunks: + all_chunks.append({"type": "paragraph", "chunks": paragraph_chunks}) + + return all_chunks + + async def generate_audiobook(self, text_file_path): + """ + Generate an audiobook from a text file. + + Args: + text_file_path: Path to the text file to convert + + Returns: + Path to the generated audiobook file + """ + # Read the text file + text_path = Path(text_file_path) + if not text_path.exists(): + raise FileNotFoundError(f"Text file not found: {text_file_path}") + + with open(text_path, 'r', encoding='utf-8') as f: + text = f.read() + + print(f"Processing text file: {text_file_path}") + print(f"Text length: {len(text)} characters") + + # Split text into chunks + paragraphs = self.split_text_into_chunks(text) + total_chunks = sum(len(p["chunks"]) for p in paragraphs) + print(f"Split into {len(paragraphs)} paragraphs with {total_chunks} total chunks") + + # Generate audio for each chunk + segment_results = [] + chunk_count = 0 + + # Pre-load model if keeping it loaded + if self.keep_model_loaded: + print("Pre-loading TTS model for batch processing...") + self.tts_service.load_model() + + try: + for para_idx, paragraph in enumerate(paragraphs): + print(f"Processing paragraph {para_idx+1}/{len(paragraphs)}") + + for chunk_idx, chunk in enumerate(paragraph["chunks"]): + chunk_count += 1 + self.chunk_counter += 1 + print(f" Generating audio for chunk {chunk_count}/{total_chunks}: {chunk[:50]}...") + + # Generate unique filename for this chunk + segment_filename_base = f"{self.output_base_name}_p{para_idx}_c{chunk_idx}_{uuid.uuid4().hex[:8]}" + + try: + # Get absolute speaker sample path + speaker_sample_path = Path(self.speaker_info.sample_path) + if not speaker_sample_path.is_absolute(): + from backend.app.config import SPEAKER_DATA_BASE_DIR + speaker_sample_path = SPEAKER_DATA_BASE_DIR / speaker_sample_path + + # Generate speech for this chunk + if self.use_subprocess: + # Use subprocess for guaranteed memory release + segment_output_path = await self._generate_chunk_subprocess( + chunk=chunk, + segment_filename_base=segment_filename_base, + speaker_sample_path=speaker_sample_path + ) + else: + # Load model for this chunk (if not keeping loaded) + if not self.keep_model_loaded: + print("Loading TTS model...") + self.tts_service.load_model() + + # Generate speech using the TTS service + segment_output_path = await self.tts_service.generate_speech( + text=chunk, + speaker_id=self.speaker_id, + speaker_sample_path=str(speaker_sample_path), + output_filename_base=segment_filename_base, + output_dir=self.temp_dir, + exaggeration=self.exaggeration, + cfg_weight=self.cfg_weight, + temperature=self.temperature + ) + + # Memory management strategy based on model lifecycle + if self.use_subprocess: + # No memory management needed - subprocess handles it + pass + elif self.keep_model_loaded: + # Light cleanup after each chunk + if self.chunk_counter % self.cleanup_interval == 0: + print(f"Performing periodic deep cleanup (chunk {self.chunk_counter})") + self._cleanup_memory() + else: + # Explicit memory cleanup after generation + self._cleanup_memory() + + # Unload model after generation + print("Unloading TTS model...") + self.tts_service.unload_model() + + # Additional memory cleanup after model unload + self._cleanup_memory() + + # Add to segment results + segment_results.append({ + "type": "speech", + "path": str(segment_output_path) + }) + + # Add pause between sentences + if chunk_idx < len(paragraph["chunks"]) - 1: + segment_results.append({ + "type": "silence", + "duration": self.pause_between_sentences + }) + + except Exception as e: + print(f"Error generating speech for chunk: {e}") + # Ensure model is unloaded if there was an error and not using subprocess + if not self.use_subprocess: + if not self.keep_model_loaded and self.tts_service.model is not None: + print("Unloading TTS model after error...") + self.tts_service.unload_model() + # Force cleanup after error + self._cleanup_memory() + # Continue with next chunk + + # Add longer pause between paragraphs + if para_idx < len(paragraphs) - 1: + segment_results.append({ + "type": "silence", + "duration": self.pause_between_paragraphs + }) + + finally: + # Always unload model at the end if it was kept loaded + if self.keep_model_loaded and self.tts_service.model is not None: + print("Final cleanup: Unloading TTS model...") + self.tts_service.unload_model() + self._cleanup_memory() + + # Concatenate all segments + print("Concatenating audio segments...") + concatenated_filename = f"{self.output_base_name}_audiobook.wav" + concatenated_path = self.output_dir / concatenated_filename + + self.audio_manipulator.concatenate_audio_segments( + segment_results=segment_results, + output_concatenated_path=concatenated_path + ) + + # Create ZIP archive with all files + print("Creating ZIP archive...") + zip_filename = f"{self.output_base_name}_audiobook.zip" + zip_path = self.output_dir / zip_filename + + # Collect all speech segment files + speech_segment_paths = [ + Path(s["path"]) for s in segment_results + if s["type"] == "speech" and Path(s["path"]).exists() + ] + + self.audio_manipulator.create_zip_archive( + segment_file_paths=speech_segment_paths, + concatenated_audio_path=concatenated_path, + output_zip_path=zip_path + ) + + print(f"Audiobook generation complete!") + print(f"Audiobook file: {concatenated_path}") + print(f"ZIP archive: {zip_path}") + + # Ensure model is unloaded at the end (just in case) + if self.tts_service.model is not None: + print("Final check: Unloading TTS model...") + self.tts_service.unload_model() + + return concatenated_path + +async def main(): + parser = argparse.ArgumentParser(description="Generate an audiobook from a text file using Chatterbox TTS") + + # Create a mutually exclusive group for the main operation vs listing speakers + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--list-speakers", action="store_true", help="List available speakers and exit") + group.add_argument("text_file", nargs="?", help="Path to the text file to convert") + + # Other arguments + parser.add_argument("--speaker", "-s", help="ID of the speaker to use") + parser.add_argument("--output", "-o", help="Base name for output files (default: derived from text filename)") + parser.add_argument("--device", default="mps", choices=["mps", "cuda", "cpu"], help="Device to use for TTS (default: mps)") + parser.add_argument("--exaggeration", type=float, default=0.5, help="Controls expressiveness (0.0-1.0, default: 0.5)") + parser.add_argument("--cfg-weight", type=float, default=0.5, help="Controls alignment with speaker (0.0-1.0, default: 0.5)") + parser.add_argument("--temperature", type=float, default=0.8, help="Controls randomness (0.0-1.0, default: 0.8)") + parser.add_argument("--sentence-pause", type=float, default=0.5, help="Pause between sentences in seconds (default: 0.5)") + parser.add_argument("--paragraph-pause", type=float, default=1.0, help="Pause between paragraphs in seconds (default: 1.0)") + parser.add_argument("--keep-model-loaded", action="store_true", help="Keep model loaded between chunks (faster but uses more memory)") + parser.add_argument("--cleanup-interval", type=int, default=10, help="How often to perform deep cleanup when keeping model loaded (default: 10)") + parser.add_argument("--force-cpu-on-oom", action="store_true", help="Automatically switch to CPU if MPS/CUDA runs out of memory") + parser.add_argument("--max-chunk-length", type=int, default=300, help="Maximum chunk length for text splitting (default: 300)") + parser.add_argument("--use-subprocess", action="store_true", help="Use separate processes for each chunk (guarantees memory release but slower)") + + args = parser.parse_args() + + # List speakers if requested + if args.list_speakers: + speaker_service = SpeakerManagementService() + speakers = speaker_service.get_speakers() + print("Available speakers:") + for speaker in speakers: + print(f" {speaker.id}: {speaker.name}") + return + + # Validate required arguments for audiobook generation + if not args.text_file: + parser.error("text_file is required when not using --list-speakers") + + if not args.speaker: + parser.error("--speaker/-s is required when not using --list-speakers") + + # Determine output base name if not provided + if not args.output: + text_path = Path(args.text_file) + args.output = text_path.stem + + try: + # Create audiobook generator + generator = AudiobookGenerator( + speaker_id=args.speaker, + output_base_name=args.output, + device=args.device, + exaggeration=args.exaggeration, + cfg_weight=args.cfg_weight, + temperature=args.temperature, + pause_between_sentences=args.sentence_pause, + pause_between_paragraphs=args.paragraph_pause, + keep_model_loaded=args.keep_model_loaded, + cleanup_interval=args.cleanup_interval, + use_subprocess=args.use_subprocess + ) + + # Generate audiobook with automatic fallback + try: + await generator.generate_audiobook(args.text_file) + except (RuntimeError, torch.OutOfMemoryError) as e: + if args.force_cpu_on_oom and "out of memory" in str(e).lower() and args.device != "cpu": + print(f"\n⚠️ {args.device.upper()} out of memory: {e}") + print("🔄 Automatically switching to CPU and retrying...") + + # Create new generator with CPU + generator = AudiobookGenerator( + speaker_id=args.speaker, + output_base_name=args.output, + device="cpu", + exaggeration=args.exaggeration, + cfg_weight=args.cfg_weight, + temperature=args.temperature, + pause_between_sentences=args.sentence_pause, + pause_between_paragraphs=args.paragraph_pause, + keep_model_loaded=args.keep_model_loaded, + cleanup_interval=args.cleanup_interval, + use_subprocess=args.use_subprocess + ) + + await generator.generate_audiobook(args.text_file) + print("✅ Successfully completed using CPU fallback!") + else: + raise + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + return 0 + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/cbx-dialog-generate.py b/cbx-dialog-generate.py index afb975c..3e1b9f0 100644 --- a/cbx-dialog-generate.py +++ b/cbx-dialog-generate.py @@ -6,6 +6,9 @@ import yaml import torchaudio as ta from chatterbox.tts import ChatterboxTTS +# Import helper to fix Python path +import import_helper + def split_text_at_sentence_boundaries(text, max_length=300): """ Split text at sentence boundaries, ensuring each chunk is <= max_length. diff --git a/cbx-generate.py b/cbx-generate.py index 4e1594f..db1a082 100755 --- a/cbx-generate.py +++ b/cbx-generate.py @@ -1,22 +1,77 @@ import argparse +import gc +import torch import torchaudio as ta from chatterbox.tts import ChatterboxTTS +from contextlib import contextmanager + +# Import helper to fix Python path +import import_helper + +def safe_load_chatterbox_tts(device): + """ + Safely load ChatterboxTTS model with device mapping to handle CUDA->MPS/CPU conversion. + This patches torch.load temporarily to map CUDA tensors to the appropriate device. + """ + @contextmanager + def patch_torch_load(target_device): + original_load = torch.load + + def patched_load(*args, **kwargs): + # Add map_location to handle device mapping + if 'map_location' not in kwargs: + if target_device == "mps" and torch.backends.mps.is_available(): + kwargs['map_location'] = torch.device('mps') + else: + kwargs['map_location'] = torch.device('cpu') + return original_load(*args, **kwargs) + + torch.load = patched_load + try: + yield + finally: + torch.load = original_load + + with patch_torch_load(device): + return ChatterboxTTS.from_pretrained(device=device) def main(): parser = argparse.ArgumentParser(description="Chatterbox TTS audio generation") parser.add_argument('--sample', required=True, type=str, help='Prompt/reference audio file (e.g. .wav, .mp3) for the voice') parser.add_argument('--output', required=True, type=str, help='Output audio file path (should end with .wav)') parser.add_argument('--text', required=True, type=str, help='Text to synthesize') + parser.add_argument('--device', default="mps", choices=["mps", "cuda", "cpu"], help='Device to use for TTS (default: mps)') args = parser.parse_args() - # Load model on MPS (for Apple Silicon) - model = ChatterboxTTS.from_pretrained(device="mps") + model = None + wav = None + + try: + # Load model with safe device mapping + model = safe_load_chatterbox_tts(args.device) - # Generate the audio - wav = model.generate(args.text, audio_prompt_path=args.sample) - # Save to output .wav - ta.save(args.output, wav, model.sr) - print(f"Generated audio saved to {args.output}") + # Generate the audio + with torch.no_grad(): + wav = model.generate(args.text, audio_prompt_path=args.sample) + + # Save to output .wav + ta.save(args.output, wav, model.sr) + print(f"Generated audio saved to {args.output}") + + finally: + # Explicit cleanup + if wav is not None: + del wav + if model is not None: + del model + + # Force cleanup + gc.collect() + if args.device == "cuda" and torch.cuda.is_available(): + torch.cuda.empty_cache() + elif args.device == "mps" and torch.backends.mps.is_available(): + if hasattr(torch.mps, "empty_cache"): + torch.mps.empty_cache() if __name__ == '__main__': main() diff --git a/import_helper.py b/import_helper.py new file mode 100644 index 0000000..3d7a3e0 --- /dev/null +++ b/import_helper.py @@ -0,0 +1,31 @@ +""" +Import helper module for Chatterbox UI. + +This module provides a function to add the project root to the Python path, +which helps resolve import issues when running scripts from different locations. +""" + +import sys +import os +from pathlib import Path + +def setup_python_path(): + """ + Add the project root to the Python path. + This allows imports to work correctly regardless of where the script is run from. + """ + # Get the project root (parent of the directory containing this file) + project_root = Path(__file__).resolve().parent + + # Add the project root to the Python path if it's not already there + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + print(f"Added {project_root} to Python path") + + # Set environment variable for other modules to use + os.environ["PROJECT_ROOT"] = str(project_root) + + return project_root + +# Run setup when this module is imported +project_root = setup_python_path() \ No newline at end of file diff --git a/predator.txt b/predator.txt new file mode 100644 index 0000000..c1c7554 --- /dev/null +++ b/predator.txt @@ -0,0 +1,129 @@ +He watched from the pickup, his feet dangling off of the end of the tailgate as he sipped a beer and swung his boots back and forth. He adjusted the Royals baseball cap and leaned back on his left hand, languid in the warm summer evening, the last bit of sun having disappeared just ten minutes ago, bringing surcease from the ridiculous August heat in Missouri. The high, thin clouds were that beautiful shade of salmon that made this the end of the “Golden Hour”. His black tank top was damp from sweat but his jeans were clean and he still smelled good. + +The girl got out of a blue Prius that looked black under the flickering yellow pall of a high-pressure sodium light in the next row of the parking lot. She had glossy, dark hair that fell in waves to her shoulders that were bared by a ribbed white tank top, its hems decorated with lace, baring her gold belly-button-ring. She wore cutoff jeans shorts - they just missed the “daisy” appellation, but were short enough, with a frill of loose cotton threads neatly trimmed into a small white fringe at the bottom of each cut-off leg - and brown sandals with wedge heels and leather laces up her ankles to her calves. A tiny brown leather clutch swung from a strap across her body, and she tucked her car keys in it, snapped it closed, and let it fall to her side. She was very lightly tanned, much paler than most he’d seen around here. Her body was shapely; no bra, full breasts, narrow waist. Supple curves arced from her hips to her thighs and toned calves. Her fingers seemed both slender and strong, and she managed to look both muscular and soft. She started off towards the entrance to the fair at a brisk pace, her footfalls light, but she jiggled and bounced in interesting - and likely, intentional - ways. +He admired the way the muscles of her calves flexed to maintain her balance. She was breathtaking. + +He hopped off of the tailgate, his boots crunching in the gravel, then closed it, silently, and began to follow her to the gate. He knew she’d turn to the right after going through the gate; he wasn’t worried he would lose her. He didn’t know how he knew - he could just tell. Hell, even if she didn’t, it was a big county fair, but not so big he couldn’t find one pretty little brunette. As he walked, hips loose, boots crunching in the gravel, fifty feet back, he wondered if she was meeting anyone. He was far enough away that she didn’t even glance back. He knew from experience if he broke into a run she’d look back with the instinctive fear of a prey animal - a lovely, country-flavored gazelle or white tail on her cloven hooves of braided leather and cork. He didn’t want her to know she was being pursued - not yet. That wasn’t the game. + +The fresh gray gravel was still hot from the sun, radiating heat into his feet and the air above it, along with the scent of dust and rock. It was mounded in the center, pressed down in the places where car tires rolled over it. Grasses pushed up to the edge of the gravel parking lot of the county fair. A slight breeze brought the distant smells of funnel cakes, hotdogs, and cotton candy. The lights of the fair were visible beyond the surrounding fence. He felt sweat in the small of his back and on his upper lip. Night was coming, though, and the temperature would continue to drop. + +She reached the ticket ‘booth’ - which was a folding card table flanked by bales of straw, manned by a fat, middle-aged, bleach-blonde woman and what he presumed was her fat, bored offspring. Mouth breathers, he observed. He could smell them from here, stale sweat, cigarette smoke, cheap cologne. He heard the girl’s voice, a rich, lilting contralto that made him feel like salivating. “Just one, please.” + +“Adult?” asked the bored woman, not even looking up, just staring at the roll of tickets, the money box, the electronic payment device. The girl laughed and it rang through him like a bell, inflaming a hunger he knew well. “Yes, please.” she replied, waving her phone at the point-of-sale payment device. It chimed and the woman handed her a ticket and a bright orange wristband, then waved her on. “Have fun!” she called after the girl, in a voice so empty of enthusiasm it seemed to suck happiness from the very air around it. She had a KC Chiefs tee shirt and black jeans stretched to their tensile limit. He assumed she had some boots on as well, a rural affectation common in this county where there was more forest than cattle. + +When he arrived at the table, she regarded him with dead, watery faded blue eyes. “Adult?” + +“Yep.” He didn’t even bother to laugh. + +“Ten bucks.” She picked up the ticket and wrist band and waited expectantly. He pulled a ten from his pocket and laid it on the table in front of her. She took it and handed it to the kid, who was wearing a tee-shirt emblazoned with the words “Let’s go Brandon”. “Have fun.” she repeated, just as enthusiastically as before, tucking a damp lock of chemically altered hair behind her left ear. He grunted noncommittally and strolled after his gazelle, who’d gone out of sight - to his right, of course. Towards the paved area of the fair, where carnival rides and games blasted forth a cacophony of light and noise into the hot midwestern night, the smell of hot dogs, popcorn, and cotton candy vying for the attention of the press of fairgoers in their cowboy boots, jeans, and short skirts. Tee shirts here and there, and sometimes a pair of overalls, but it could have been a uniform. The people here were largely overweight, trending to dangerously obese, massive instances of humanity that lumbered, stomped, or waddled from game to game and food cart to food cart. He watched a dark-haired, overall-clad man who was at least six foot six and had to weigh 400 lbs on the hoof consume an enormous hot dog as though it were a light snack, in three quick bites, grease, mustard, and cheese running down his hand. He licked cheese from the back of his hand and wiped his hands on his capacious pants legs. He had a handgun in a high hip holster. Open carry was in evidence everywhere, peppered across demographics, from shapely young women with Glocks to octogenarians sporting well-worn 1911s and white flat-tops. It was Missouri, after all. He didn’t need or want a gun, and it wouldn’t do them any good if he turned his attention to them. + +Cops were scattered through the fairground. Some were clearly private security, others might have been local police, sheriffs, or even highway patrol, for all he knew. There were at least four uniforms represented. Cops didn’t concern him. He didn’t look dangerous or threatening, and none looked at him directly, no scanning eyes paused on him or tracked his progress across the straw-strewn asphalt. It could get inconvenient if police became involved, of course, but he didn’t worry much. + +He’d gotten distracted, and was surprised as he nearly ran into his gazelle as she came around the end of a food cart, and he stopped suddenly to avoid bowling her over. She smiled and said “Excuse me!” and kept walking. “No problem!” he called after her, grinning at her back. It was good, he thought; interaction was key to breaking the ice later. Folks often walk the same direction through an exhibition like a fair, so being in the same general area over time wasn’t unusual and she’d never know he was stalking her. They never figured it out, not before he wanted them to figure it out. He was an attractive, friendly looking man with an open, disarming smile, medium brown hair, a strong, muscular body, capable, competent, without being threatening. He was tall, but not surprisingly so - six feet nothing, maybe a hair more in his boots. A hundred and eighty pounds on most days, no belly but not sporting a sharp six pack either. Women found him attractive but not threatening, which was his intention. His eyes were blue and he had a well-trimmed mustache and the slightest hint of stubble. He watched her without looking at her, noting that she was alone, but kept checking her phone, occasionally texting someone. If her friends didn’t show up it would make it easier for him to get her attention, to draw her in. + +He floated near her, just exploring the fair in the same sequence, seemingly by chance. He paid $3 to play a game of chess against a fellow who was playing 12 people simultaneously. Overhead light from an LED lamp on a pole lit a rectangle of narrow tables, four chess boards on each. The man playing chess was dressed like one might imagine Sherlock Holmes, with a pipe clamped in his teeth. Sherlock walked clockwise around the rectangle making a move on each board as he came to it. He crushed the “chess master” in eighteen moves and moved on before the man could comment. He threw darts at balloons while watching her from the corner of his eye as she tried to ring a bell by swinging a hammer. He saw her check her phone again and look exasperated, her full lips pursing in frustration at something she read on the screen. She shrugged and looked around, almost catching him staring. Her eyes roamed the area and paused for the tiniest second on his profile, then swept along to take in the rest of the area. He strolled slowly to the next attraction, which was a booth where one could pay $5 to throw three hatchets at targets for prizes. There was a roof held up by four-by-fours spaced every five or six feet; each pair of four-by-fours made a lane for throwing axes, and there was a big target at the end of each lane, maybe twenty feet away. Five lanes, the ubiquitous straw strewn over the asphalt - to give that barnyard feel, he thought. He stepped up and handed the barker a twenty. The man was cajoling onlookers, almost chanting, about trying your luck and winning prizes throwing the axes, and his voice never faltered. He had a belt pouch that contained change, and was wearing worn jeans, worn athletic shoes, and a worn tee-shirt from a rock concert of a band long forgotten in this day and age. Belt Pouch put three axes in the basket next to the lane opening, put three fives on the small change shelf and stepped aside, making the twenty vanish into the pouch. + +He picked up the first ax and measured its weight in his hand. He judged the distance and tossed the ax overhand in a smooth gesture. It struck head-first with a loud thump and fell to the ground, the head clanging against the asphalt.. He picked up the next ax and tossed it without any theatrics and it stuck solid, outside the bullseye.. He flipped the third after it almost nonchalantly and it stuck next to its sibling, this time at the edge of the red circle. Belt Pouch paused for an instant, and retrieved the thrown axes, offering them to him, and he accepted with a nod. The carnie’s patter changed, saying something about watching an expert at work. He tossed all three, rapidly, one after the other, and they lined up on the bullseye, separated by a hair’s breadth. The carnie laughed, and he heard a low whistle. A breeze swirled some loose bits of straw and cooled the light sweat on his back. + +“Impressive.” she said, her voice rich and beautifully textured. + +He shrugged. The carnie gathered the axes and offered them to him again. He nodded, not paying any attention to the man. “Wanna try it?” he asked the gazelle. + +Her eyes were ice blue - he had expected them to be brown! - and long, dark lashes veiled them when she blinked. Her makeup was understated, but perfect - a dash of color and shadow. She cocked her head to one side, evaluating him, her lips curving slightly at the corners, the smile staying mostly in her eyes. She seemed to come to a decision and shrugged, then nodded. “Sure, why not? You make it look pretty easy!” She stepped up next to him and he yielded some space to allow her the center of the throwing lane. A couple of men in jeans and cowboy boots had stopped to watch, idly glancing from the target to him, then to her, their thumbs hooked in their belt loops. Their eyes lingered carefully on her, he could see, and they missed nothing. But she was his now. They would know better. The same way a jackal knew that the lion’s food was not for him. + +She held out her hand and said, “Kim.” He took it, smooth and warm, and nodded. “Dave.” It wasn’t his name. Hell, hers probably wasn’t “Kim”. He knew how this sort of thing went. If he’d been a normal man, at the end of the night she’d have written a fake phone number on his palm and made him promise to call. “Nice to meet you, Dave.” She smiled a little and held out a hand. He passed her one of the hatchets and she bounced it in her hand, holding it like a hammer. “Heavier than it looks!” she observed. + +“Have you done this before?” + +She shook her head. “Is there a trick to it?” + +“Isn’t there always?” + +She laughed and shrugged, then concentrated. She drew back, holding it more like he had, concentrating with a small frown, and smoothly flung it down-range. It struck handle-first and fell to the floor. Boom-clang. “Shit.” + +“It’s your first try! Don’t be so hard on yourself.” he said, offering her the second worn ax, handle first. She took it and grinned. He glanced around, noting that at least four men were watching her carefully now, along with Belt Pouch, who’d resumed his half-hearted patter about trying your luck and winning prizes, but was watching the couple with interest. Another breeze stirred some loose straw and made her hair flutter a bit as she turned and set her feet. She scuffed a foot on the asphalt. “I’d probably do better with sneakers or boots. High heels aren’t really ideal for this sort of thing, I bet.” Concentrating, she drew back the ax, holding it almost exactly as he had, and smoothly tossing it downrange, where it stuck. Not in the bullseye, but on the target. + +“Not too shabby!” He nodded approvingly, offering her the last ax. She flashed him a grin and took it, shifting her stance and her grip, then in one smooth motion, the ax sailed smoothly to the target and stuck, on the very edge of the red circle, just outside the bullseye. “Nice!” he said, grinning. + +“I guess you made it look too easy.” She leaned against the 4x4, looking at him speculatively. “Win something for me.” She grinned, white teeth with the slightest hint of irregularity shining in the LED light.. “A teddy bear, or a beer hat, or, you know, something fair-appropriate. You can do it, right?” + +He paused for a moment, regarding her. “Perhaps.” He glanced at the carnie and jerked his head, and the carnie correctly interpreted the motion and retrieved the axes and picked up the last five. “What can I win?” + +“You’ve already got some points racked up, so one bullseye will get you anything on this shelf.” He indicated a shelf littered with various sorts of toys, stuffed animals, lighters, and the like. + +“What about three bullseyes?” + +“That’s this shelf.” There was nothing obviously different about the two shelves except the “points” on the label, and the fact that it was the highest one, but he nodded. He turned back downrange and tossed all three in a smooth, mechanical sequence, and they once again lined up on the bullseye, thunk-thunk-thunk. The carnie looked at him, his gaze unreadable, and pointed at the highest shelf. “What can I get you?” + +‘Dave’ glanced at the gazelle. “Kim? Choose your prize.” He grinned. + +Her eyes flashed a grin in return and she stepped up to the rail, pointing. “That, right there.” It wasn’t a teddy bear. It was a cheap ripoff of a Zippo lighter with a praying mantis enameled onto the front in green, yellow, and black. The carnie shrugged and plucked it from the shelf and deposited it in her hand. She weighed it in her palm and flicked it open and closed a few times. + +“It won’t work.” the carnie said. “No fluid in it. You’ll have to load it up when you get home.” + +She nodded and turned back to ‘Dave’. “Wanna get a beer?” + +He nodded. “Sure. Just one, though. I’m driving.” Together they threaded through the crowd to a place that had beer signs on posts. He noted the eyes of strangers on her as they made their way, and he grinned to himself. There was lust and jealousy and frustration in the eyes of the men. She really was quite attractive. A couple of women looked irritated, the way women sometimes do when a beautiful woman draws the attention of a man they feel belongs to them. + +The “bar” was a roped off area set with high bar tables and stools, looking over a broad bit of straw-strewn ground where someone had erected a mechanical bull. It was surrounded with layers of foam pads a couple of inches thick, laid out so that the drunks tossed from the bull’s back wouldn’t end up traumatized in the emergency room, or worse. A couple of huge, slowly turning fans created a constant moderate breeze that felt good in the humid night air. Her hair fluttered as she hooked a foot into a stool and swung up onto the stool, to put her elbows down on the round tabletop, which was a mosaic of beer bottle caps entombed in some scuffed, clear plastic resin. Napkins, ketchup, mustard, and other condiments inhabited a little rack, along with salt and pepper packets. A waitress materialized at his elbow and mumbled something that ended in “... getcha?” He could smell a fryer and the aromas of bar food. Hot wings, french fries, hamburgers, nachos. He wasn’t interested in that sort of thing, though. + +Kim glanced at the beer menu clipped in a metal ring on the condiment carrier and tapped one - a mass market IPA. He held up two fingers, the waitress said, “Got it” and turned away. He hadn’t wanted any food but was momentarily irritated that the mousy, pale woman hadn’t asked him or his date. Kim grinned at him as though she could read his thoughts. One manicured finger tapped the table top and she cocked her head to one side again. “So, ‘Dave’, what do you do?’ + +He crossed his arms and met her gaze. What was the right answer for this one? Hard working laborer, or executive out to play? Salesman, computer nerd, actor? “Guess” he finally said. “What do you think I do?” + +“Go to county fairs to meet women.” Her reply was immediate, as though she’d known what he was going to say. “Professional ax thrower. Maybe you’re secretly a carnie on a night off?” + +She wore a tiny cross on a chain and a pair of stud earrings that were just bright golden spheres against her earlobes. He decided she wasn’t the sort to see herself as a gold digger and shrugged. “I work in a warehouse. Drive a forklift.” + +“A workin’ man, eh? Union? I hear forklift driving is a decent gig if it's a union job.” + +“Decent enough.” He shrugged. “Paid for my truck, keeps me in meals. It isn’t for everyone, but I like it.” He shifted on the stool. “You?” + +Just then the waitress returned with two bottles on a tray. “Ten dollars.” He took the bottles and dropped a ten and a five on the tray and she vanished without a word. Kim took a sip from the condensation-shrouded bottle and said “I do books. Taxes, accounting, that sort of thing. Got a four year degree in accounting and left for the big city - Lebanon, Missouri. It pays the bills.” + +“Ever think about getting out?” he asked. “Heading for the big city? New York, Paris, you know. Bright lights and parties?” It was the question every rural and small town dweller asked themselves at some point. Cities were too dangerous for his kind, of course, but he knew how these people thought. + +“Nah, not much. Nothing there for me. I have friends and family here.” + +“That why you’re here alone?” + +“My sister’s car broke down. Shit happens. And I’m not alone, right?” + +He shrugged, nodded, then took a sip of cold, bitter, hoppy beer. “Why’d you pick that thing?” he asked, suddenly, pointing at the cheap zippo ripoff. + +She shrugged. “I’ve just always loved praying mantises. They seem intelligent. They turn their head to watch you, and sometimes they’ll dance with you.” She turned the lighter so the mantis was up, and opened the top. “They’re related to walking sticks. There’s one in Indonesia that looks like an orchid; it’s evolved to pretend to be an orchid until the food gets close to what it sees as a flower. It’s gorgeous, the same pastel colors as the orchids it sits on, all pinks and blues and purples.” She shut the lighter suddenly. “Then snap, the mantis moves like lightning and … dinner!” + +“Sounds dangerous!” He grinned and tossed back most of the beer. He could feel her relaxing, the darkness that drove him a burning hunger in his chest. His skin felt like it was rippling with electricity and he could smell her, delicate, rich, delicious. For a moment he saw a glowing outline around her as his hunger grew. He set the bottle back down and waved away the waitress as she stepped forward to see if he wanted more. Kim took a long pull on hers and tossed the almost empty bottle into the trash bin a few feet away. + +“Let’s go wander around, see what there is to see.” She slid off the stool and stretched fetchingly, her tiny purse bouncing against her trim belly. They slipped out of the roped-off ‘bar’ area into the crowd. They watched a few drunks get dumped off the mechanical bull and laughed. She refused his challenge to get on it, and he refused hers in turn. They wandered through the fair, watching people and talking. She put her hand on his arm and pointed. “Let’s go get our fortunes read!” There was a squared-off trailer with a sign that said “Tarot, fortunes told, palms read, loved ones contacted” It was painted lots of colors and there was a small sign over the door that said “Entrance”. There were the ubiquitous straw bales delineating a small courtyard with eight chairs - all empty except one, inhabited by a tall, slender woman with frosted blond hair and dark eyes. She was cajoling passers by with promises of answers about love, life, and the future. When they turned into the tiny “courtyard” of hay bales, the woman stepped in front of them, holding up her hands. She shook her head. “This is not for you” she said, eyes hooded and giving up nothing. “We don’t need your money.” He thought for a moment he saw a glow in her eyes, and there were definitely faint glowing outlines around the door. + +“What was that all about?” Kim asked, looking back over her shoulder, her voice betraying some mild irritation. “This is not for you!” she mimicked the woman’s voice derisively. “What did I do? Do you know them? I’ve never seen them before.” + +He shook his head. “I don’t know them.” But he did. He knew her kind. The darkness inside him gave her a name. Witch. But Kim wouldn’t find that amusing if spoken aloud. He glanced back and saw the woman making a hand gesture at their backs, her thumb clamped between her index and middle finger. It couldn’t hurt him, but witches had been known to have … helpers; helpers who could hurt him. + +“Eh, it’s just as well.” she said. “It’s getting late, I’m tired, and I should probably head home.” + +“So soon?” he let disappointment creep into his voice. “Can I see you again?” It was the game, and he played it well. + +She smiled and shook her head. “This wasn’t that kind of date, ‘Dave’. You know it, I know it. I won’t even write a fake number on your hand and implore you to call me sometime.” + +He looked at her, a bit surprised. “You too good for a forklift driver?” + +Her eyebrow raised and her blue eyes sparkled. “No, of course not. I just have a policy about men I meet alone at the fair. I know why you came here alone.” + +He shrugged. It wouldn’t matter anyway. He’d catch her in the parking lot and it wouldn’t make any difference at all. She couldn’t get away; he’d already chosen her. He would just miss that delicious moment where the prey, having surrendered her trust, would suddenly recognize the error she had made and comfort would turn to terror, her heart leaping in fear and hammering against her ribs, her eyes going wide and her breasts heaving, nipples erect with fear and adrenaline as he forced her down with hands too strong for his size and build. This one would already be frightened when he got his hands on her. It would still be delicious, though. She might survive. Some did, empty husks, devoid of everything that makes life rich and beautiful, empty of life, of love, soulless in a sense. Most did not survive, giving up the spark of life along with the flame that he took. + +She must have seen something in his eyes, then, because she looked a little uncomfortable. She waved her hand at him and started towards the gate, walking quickly without looking back. He could feel the tension in her body, the fear. She was already telling herself she was being ridiculous though, telling herself that he wasn’t a danger to her. She turned the corner around a food cart onto one of the fair’s rows of games and shops and walked out of sight, carefully not looking back. He knew that she’d glance over her shoulder as soon as she thought she was out of sight. He moved, quickly, but not running, not drawing undue attention. He slipped between a couple of trailers and stepped over the mobile rail that marked off the fair from the fields around it and moved out of the light. Then he ran, his feet light, his heart beating, the thrill of the hunt coursing through his veins and the darkness within him crying out a wordless “YES”. + +He rounded a large red shipping container that marked the edge of the parking lot and slipped in between the rows of trucks and SUVs. There weren’t many people there, but there was Kim, walking from the gate and almost trotting towards her Prius, glancing back over her shoulder furtively. He took a deep breath and could smell her rich scent, now tinged with fear and exertion, making his skin tingle and buzz with energy. He ducked low and paced along silently, just behind the row of cars where her Prius sat waiting. She gained the car and he was mere feet from her when he stood and said “Hi.” + +She yelped, a sharp, bright sound, and bolted, sprinting between the cars and out towards the open field and the woods a hundred feet beyond. He laughed and didn’t even care that two cops had heard her and were running after him. He trotted lightly after her, wanting her to make it to the trees before he caught her, but the cops were faster than her and were gaining on him. “On the ground!” one shouted, and drew a taser. ‘Dave’ juked to one side, turned suddenly, faster than humanly possible, and drove a rigid hand into the neck of the pursuing cop, crushing his trachea and driving a shockwave into his spine. The cop was unconscious before he hit the ground, and ‘Dave’ was ducking and rolling toward the other cop who hadn’t quite realized what had happened. The second cop got his gun out but ‘Dave’ had his hand on it before it cleared the holster, and he stripped it away, taking some of the cop’s hand with it and silencing the man’s sudden shout of pain with another vicious blow to the throat, the butt of the pistol crushing through cartilage and driving a vertebra so far out of alignment with the rest of his spine it severed the cord, the magic string, and he fell to the ground like roast and potatoes spilled from a platter. The world fell silent again except for the sound of her running feet, getting close to the trees. + +His blood was singing and the darkness in him filled him to bursting, rendering the night in sharp relief, enabling him to see in this blackness as well as he could during the day. He could see her in the trees, a glowing body of beauty and heat and life, scrambling between the trees and trying to put distance between them. He moved silently, but fast, too fast for a human, for he was not only human, not at all. He was a predator, a hunter, and she was his meat. He was not a vampire, nor an incubus, but those legends might have originated with tales of creatures like him, creatures of darkness and stealth that lived on the delicious life of the prey they had hunted through the ages. + +He drew even with her, silent in the darkness, and he could see her as though it were noon. Her eyes were wide and staring, rolling back and forth - he knew she couldn’t see him at all. He stepped down hard to break a twig and she froze at the sudden snap, staring around, trying to keep from breathing too loudly. She crept forward, trying to be quiet, trying to escape, without knowing it was already far too late. He stepped close to her and touched her neck with a gentle finger. Her entire body spasmed and she made a quiet, breathless whimpering sound, lunging away from his touch. He could see her trying to produce the scream trapped in her mind, but terror stole her breath and all that escaped was a croaking sound. He stepped close and ripped her tank top from her in a single move, exposing her body to his vision and his alone. She covered her breasts and whimpered, backing away from where she thought he was. He took two steps and ran his hand down her torso, gently, caressing, and she thrashed again and let out a little shout. He grabbed her by the throat, lifted her, and slammed her to the ground, driving the air from her lungs, and lay on her, his face close to her ear. “No screaming!” he said, quietly, and she turned her head away and tried to push him away, ineffectual and weak. He held her down by the slender throat and clawed her shorts off with the other hand and she sobbed, trying to cover herself. He grabbed one of her wrists in each hand and spread them as far apart as he could, forcing his knees between her legs and pressing her body down with his torso. She tried to buck but it didn’t matter, it didn’t move him. Not him. She was his prey, and he was here to consume her, not to be pushed away. + +He looked into her wide, staring eyes, and thrust himself inside her. Or tried. Something was wrong - he’d missed some bit of clothing… He’d encountered something hard, like she’d been wearing some kind of chastity belt or … what the fuck? He transferred both of her wrists to his right hand and held them above her head and started to reach down to investigate and at that moment, her legs lifted and snapped around him, strong, hard, crushing him to her, his hips locked into place by legs he should have been able to push away easily but instead held him like iron bands, urging him closer. And the thing he’d mistaken for a chastity belt opened - he felt it, oh shit oh shit oh shit - and took in what he’d tried to thrust into her and bit into it with sharp teeth like hypodermic needles - he felt the loss and the rush of blood and release of pressure - and an immense, empty cold began to flood into him at that junction between them, a vacuum that sucked out of him everything he was or had ever been and the darkness in him gibbered and capered in terror it had never before known. It was his turn to wrestle weakly and ineffectually to try and break the deadly embrace. Her arms, suddenly as strong as hydraulic presses, pulled easily from his grasp and embraced him, pulling him close to her, pressing him to her body, once soft and supple, now hard and glossy. The coldness and emptiness grew in him, emptying him, and in the eldritch vision the darkness granted him he saw it, in the darkness, the dark, chitinous, triumphant, enormous body just on the other side of the veil, disguised in this world as a pale, soft, attractive girl… exactly the kind of girl he sought out, he hunted, he consumed. His thoughts spun, fear gripping him, his arms flailing uselessly as the emptiness consumed everything that was him. Then, at last, there was final darkness, and felt himself evaporating into it, and was no more. + +There was silence for a moment in the trees, and everything was still and quiet. Something stirred, something pale and slender. His body was tossed aside, empty now of everything important, and the girl stood, naked but for lace-up wedge-heeled sandals, her body soft and supple again. Her clothing re-appeared over her flesh as though it were extruded from another place, and her makeup restored itself, the smears and streaks fading back into perfect order. She smoothed the ribbed tank top, now clean again and free of leaves or litter,, ran a slender hand through her hair, and started back towards her car. + diff --git a/requirements.txt b/requirements.txt index fda3a2e..a9fb900 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ PyYAML>=6.0 torch>=2.0.0 torchaudio>=2.0.0 numpy>=1.21.0 +chatterbox-tts diff --git a/sample-audiobook.txt b/sample-audiobook.txt new file mode 100644 index 0000000..78b09f0 --- /dev/null +++ b/sample-audiobook.txt @@ -0,0 +1,21 @@ +# The Importance of Text-to-Speech Technology + +Text-to-speech (TTS) technology has become increasingly important in our digital world. It enables computers and other devices to convert written text into spoken words, making content more accessible to a wider audience. + +## Applications of TTS + +TTS has numerous applications across various fields. In education, it helps students with reading difficulties by allowing them to listen to text. For people with visual impairments, TTS serves as a crucial tool for accessing digital content. + +Mobile devices use TTS for navigation instructions, allowing drivers to keep their eyes on the road. Voice assistants like Siri and Alexa rely on TTS to communicate with users, answering questions and providing information. + +## Recent Advancements + +Recent advancements in neural network-based TTS systems have dramatically improved the quality of synthesized speech. Modern TTS voices sound more natural and expressive than ever before, with proper intonation, rhythm, and emphasis. + +Chatterbox TTS represents the cutting edge of this technology, offering highly realistic voice synthesis that can be customized for different speakers and styles. This makes it ideal for creating audiobooks, podcasts, and other spoken content with a personal touch. + +## Future Directions + +The future of TTS technology looks promising, with ongoing research focused on making synthesized voices even more natural and emotionally expressive. We can expect to see TTS systems that can adapt to different contexts, conveying appropriate emotions and speaking styles based on the content. + +As TTS technology continues to evolve, it will play an increasingly important role in human-computer interaction, accessibility, and content consumption. \ No newline at end of file