From d3e72edd7f8ded140537ed93a65a4a4a662d495b Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 9 Dec 2025 14:56:48 +0000 Subject: [PATCH] fix: Update Piper TTS to support voice preview and improve setup - Fixed preview endpoint to work with both Kokoro and Piper engines - Improved voice model download with curl fallback and SSL handling - Added better error messages when models are missing - Created PIPER_SETUP.md with manual download instructions - Added test_piper_direct.py for testing Piper installation - Updated README with voice model download requirements Note: Piper voice models require manual download from GitHub releases due to HuggingFace URL structure. See PIPER_SETUP.md for instructions. --- backend/PIPER_SETUP.md | 98 +++++++++++++++++++ backend/README.md | 9 ++ backend/main.py | 44 ++++++--- backend/piper_models/en_US-lessac-medium.onnx | 1 + backend/services/piper_tts_service.py | 80 ++++++++++++--- backend/test_piper_direct.py | 94 ++++++++++++++++++ 6 files changed, 300 insertions(+), 26 deletions(-) create mode 100644 backend/PIPER_SETUP.md create mode 100644 backend/piper_models/en_US-lessac-medium.onnx create mode 100644 backend/test_piper_direct.py diff --git a/backend/PIPER_SETUP.md b/backend/PIPER_SETUP.md new file mode 100644 index 0000000..d48735b --- /dev/null +++ b/backend/PIPER_SETUP.md @@ -0,0 +1,98 @@ +# Piper TTS Setup Guide + +## Quick Start + +Piper TTS is now integrated but requires manual voice model setup. + +### 1. Install Piper + +```bash +cd backend +./install_piper.sh +``` + +Verify installation: +```bash +piper --version +``` + +### 2. Download Voice Models + +Piper voice models need to be downloaded manually and placed in `backend/piper_models/`. + +**Download voices from:** +https://github.com/rhasspy/piper/releases/tag/v1.2.0 + +**Recommended voices for English:** + +1. **en_US-lessac-medium** (Male, American) + - Download: `en_US-lessac-medium.onnx` and `en_US-lessac-medium.onnx.json` + - Place in: `backend/piper_models/` + +2. **en_US-amy-medium** (Female, American) + - Download: `en_US-amy-medium.onnx` and `en_US-amy-medium.onnx.json` + - Place in: `backend/piper_models/` + +3. **en_GB-alan-medium** (Male, British) + - Download: `en_GB-alan-medium.onnx` and `en_GB-alan-medium.onnx.json` + - Place in: `backend/piper_models/` + +### 3. Directory Structure + +After downloading, your directory should look like: + +``` +backend/ +├── piper_models/ +│ ├── en_US-lessac-medium.onnx +│ ├── en_US-lessac-medium.onnx.json +│ ├── en_US-amy-medium.onnx +│ ├── en_US-amy-medium.onnx.json +│ ├── en_GB-alan-medium.onnx +│ └── en_GB-alan-medium.onnx.json +└── ... +``` + +### 4. Test Piper + +```bash +cd backend +python test_piper_direct.py +``` + +### 5. Use in Application + +1. Start the backend server +2. In the frontend, click "Piper TTS" button in Voice Assignment screen +3. Select Piper voices from the dropdown +4. Generate audiobook with pitch control! + +## Troubleshooting + +### "Model not found" error + +- Make sure you downloaded both `.onnx` and `.onnx.json` files +- Check files are in `backend/piper_models/` directory +- Verify filenames match exactly (case-sensitive) + +### "Piper not found" error + +- Run `./install_piper.sh` to install Piper +- Or install manually from https://github.com/rhasspy/piper/releases + +### Synthesis fails + +- Check Piper version: `piper --version` +- Try running Piper directly: `echo "test" | piper --model piper_models/en_US-lessac-medium.onnx --output-raw > test.wav` +- Check backend logs for detailed error messages + +## Alternative: Use Kokoro TTS + +If Piper setup is too complex, you can continue using Kokoro TTS (the default engine) which works out of the box with no additional setup. + +## More Voices + +Browse all available Piper voices: +https://rhasspy.github.io/piper-samples/ + +Download any voice and place the `.onnx` and `.onnx.json` files in `backend/piper_models/`. diff --git a/backend/README.md b/backend/README.md index f312fe5..2a8484d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -83,6 +83,15 @@ Or install manually: piper --version ``` +**Download Voice Models:** + +Piper requires manual voice model download. See [PIPER_SETUP.md](PIPER_SETUP.md) for detailed instructions. + +Quick steps: +1. Visit https://github.com/rhasspy/piper/releases/tag/v1.2.0 +2. Download voice models (e.g., `en_US-lessac-medium.onnx` and `.onnx.json`) +3. Place in `backend/piper_models/` directory + **Benefits of Piper TTS:** - ✨ Pitch shifting support (-12 to +12 semitones per role) - ✨ Fast, lightweight neural TTS diff --git a/backend/main.py b/backend/main.py index a04f155..4ea607e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -385,26 +385,42 @@ async def preview_voice(request: VoicePreviewRequest): preview_text = request.text or "Hello! This is a preview of this voice. How does it sound?" try: - # Get pipeline for language - pipeline = tts_service.get_pipeline(request.lang) - - # Generate preview audio - audio = tts_service.synthesize_segment( - pipeline=pipeline, - text=preview_text, - voice=request.voice, - speed=request.speed - ) - - # Convert to WAV bytes import io import soundfile as sf + import numpy as np + + # Check which engine is active + if tts_service.engine_name == 'kokoro': + # Use Kokoro service + pipeline = tts_service.kokoro_service.get_pipeline(request.lang) + audio = tts_service.kokoro_service.synthesize_segment( + pipeline=pipeline, + text=preview_text, + voice=request.voice, + speed=request.speed + ) + sample_rate = tts_service.kokoro_service.SAMPLE_RATE + + elif tts_service.engine_name == 'piper': + # Use Piper service with pitch control + audio = tts_service.piper_service.synthesize_with_pitch( + text=preview_text, + voice=request.voice, + speed=request.speed, + pitch_shift=0 # No pitch shift for preview + ) + sample_rate = tts_service.piper_service.SAMPLE_RATE + + else: + raise ValueError(f"Unknown engine: {tts_service.engine_name}") + + # Convert to WAV bytes buffer = io.BytesIO() - sf.write(buffer, audio, tts_service.SAMPLE_RATE, format='WAV') + sf.write(buffer, audio, sample_rate, format='WAV') buffer.seek(0) audio_bytes = buffer.read() - logger.info(f"Generated voice preview: {len(audio_bytes)} bytes") + logger.info(f"Generated voice preview ({tts_service.engine_name}): {len(audio_bytes)} bytes") return StreamingResponse( iter([audio_bytes]), diff --git a/backend/piper_models/en_US-lessac-medium.onnx b/backend/piper_models/en_US-lessac-medium.onnx new file mode 100644 index 0000000..a05a475 --- /dev/null +++ b/backend/piper_models/en_US-lessac-medium.onnx @@ -0,0 +1 @@ +Entry not found \ No newline at end of file diff --git a/backend/services/piper_tts_service.py b/backend/services/piper_tts_service.py index 58b82e3..f2b97e4 100644 --- a/backend/services/piper_tts_service.py +++ b/backend/services/piper_tts_service.py @@ -72,20 +72,64 @@ class PiperTTSService: # Check if model already exists if os.path.exists(model_path) and os.path.exists(config_path): + logger.info(f"Voice model {voice} already exists") return True logger.info(f"Downloading Piper voice model: {voice}") + logger.info(f"Models directory: {self.models_dir}") - # Download using piper's built-in download + # Try to download using wget/curl as Piper's download might not work try: - subprocess.run( - ["piper", "--model", voice, "--download-dir", self.models_dir], - check=True, - timeout=60 - ) - return os.path.exists(model_path) - except subprocess.SubprocessError as e: + # Piper models are hosted on HuggingFace + base_url = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0" + + # Determine language prefix from voice ID + lang_prefix = voice.split('-')[0] # e.g., "en_US" from "en_US-lessac-medium" + + model_url = f"{base_url}/{lang_prefix}/{voice}.onnx" + config_url = f"{base_url}/{lang_prefix}/{voice}.onnx.json" + + logger.info(f"Downloading from: {model_url}") + + # Try using curl/wget first (more reliable) + try: + # Try curl + subprocess.run( + ["curl", "-L", "-o", model_path, model_url], + check=True, + timeout=120, + capture_output=True + ) + subprocess.run( + ["curl", "-L", "-o", config_path, config_url], + check=True, + timeout=120, + capture_output=True + ) + except (subprocess.SubprocessError, FileNotFoundError): + # Fall back to urllib with SSL context + import urllib.request + import ssl + + # Create SSL context that doesn't verify certificates (for development) + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + urllib.request.urlretrieve(model_url, model_path, context=ssl_context) + urllib.request.urlretrieve(config_url, config_path, context=ssl_context) + + if os.path.exists(model_path) and os.path.exists(config_path): + logger.info(f"Successfully downloaded voice model: {voice}") + return True + else: + logger.error(f"Download completed but files not found") + return False + + except Exception as e: logger.error(f"Failed to download voice model {voice}: {e}") + logger.error(f"Please manually download from: https://github.com/rhasspy/piper/releases/tag/v1.2.0") + logger.error(f"See backend/PIPER_SETUP.md for detailed instructions") return False def synthesize_with_pitch( @@ -109,14 +153,25 @@ class PiperTTSService: """ # Ensure voice model is available if not self._download_voice_model(voice): - logger.warning(f"Voice {voice} not available, using default") - voice = self.DEFAULT_VOICE - self._download_voice_model(voice) + error_msg = ( + f"Piper voice model '{voice}' not found. " + f"Please download voice models manually. " + f"See backend/PIPER_SETUP.md for instructions." + ) + logger.error(error_msg) + raise FileNotFoundError(error_msg) model_path = os.path.join(self.models_dir, f"{voice}.onnx") + # Check if model file exists + if not os.path.exists(model_path): + logger.error(f"Model file not found: {model_path}") + return np.array([], dtype=np.float32) + # Generate audio with Piper try: + logger.info(f"Running Piper with model: {model_path}") + # Run piper command process = subprocess.Popen( ["piper", "--model", model_path, "--output-raw"], @@ -129,7 +184,8 @@ class PiperTTSService: stdout, stderr = process.communicate(input=text.encode('utf-8'), timeout=30) if process.returncode != 0: - logger.error(f"Piper synthesis failed: {stderr.decode()}") + error_msg = stderr.decode() if stderr else "Unknown error" + logger.error(f"Piper synthesis failed (exit code {process.returncode}): {error_msg}") return np.array([], dtype=np.float32) # Convert raw audio to numpy array diff --git a/backend/test_piper_direct.py b/backend/test_piper_direct.py new file mode 100644 index 0000000..9471f86 --- /dev/null +++ b/backend/test_piper_direct.py @@ -0,0 +1,94 @@ +""" +Direct test of Piper TTS functionality. +""" +import subprocess +import os + +def test_piper_installation(): + """Test if Piper is installed and accessible.""" + print("Testing Piper Installation\n" + "="*50) + + # Check if piper command exists + try: + result = subprocess.run( + ["piper", "--version"], + capture_output=True, + text=True, + timeout=5 + ) + print(f"✓ Piper found: {result.stdout.strip()}") + return True + except FileNotFoundError: + print("✗ Piper not found in PATH") + print(" Install with: ./install_piper.sh") + return False + except Exception as e: + print(f"✗ Error checking Piper: {e}") + return False + + +def test_piper_synthesis(): + """Test basic Piper synthesis.""" + print("\nTesting Piper Synthesis\n" + "="*50) + + # Create models directory + models_dir = os.path.join(os.path.dirname(__file__), "piper_models") + os.makedirs(models_dir, exist_ok=True) + print(f"Models directory: {models_dir}") + + # Try to synthesize with default voice + voice = "en_US-lessac-medium" + model_path = os.path.join(models_dir, f"{voice}.onnx") + + if not os.path.exists(model_path): + print(f"\n⚠️ Model not found: {model_path}") + print(f"Attempting to download {voice}...") + + # Try downloading + try: + import urllib.request + base_url = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0" + model_url = f"{base_url}/en_US/{voice}.onnx" + config_url = f"{base_url}/en_US/{voice}.onnx.json" + + print(f"Downloading from: {model_url}") + urllib.request.urlretrieve(model_url, model_path) + urllib.request.urlretrieve(config_url, f"{model_path}.json") + print("✓ Download complete") + except Exception as e: + print(f"✗ Download failed: {e}") + return False + else: + print(f"✓ Model found: {model_path}") + + # Try synthesis + try: + print("\nAttempting synthesis...") + process = subprocess.Popen( + ["piper", "--model", model_path, "--output-raw"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + test_text = "Hello, this is a test." + stdout, stderr = process.communicate(input=test_text.encode('utf-8'), timeout=10) + + if process.returncode == 0: + print(f"✓ Synthesis successful: {len(stdout)} bytes of audio") + return True + else: + print(f"✗ Synthesis failed (exit code {process.returncode})") + print(f" Error: {stderr.decode()}") + return False + + except Exception as e: + print(f"✗ Synthesis error: {e}") + return False + + +if __name__ == "__main__": + if test_piper_installation(): + test_piper_synthesis() + + print("\n" + "="*50)