diff --git a/Makefile b/Makefile index 76cd55a..1ad53b4 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ REMOTE_USER ?= $(USER) REMOTE_PATH ?= ~/uncloseai-speech CONTAINER_NAME ?= uncloseai-speech-server-1 -.PHONY: help deploy sync restart logs test clean stop start voices voices-piper voices-xtts voices-kokoro test-kokoro voices-silero test-silero voices-chatterbox test-chatterbox push-all +.PHONY: help deploy sync restart logs test clean stop start voices voices-piper voices-xtts voices-kokoro test-kokoro voices-silero test-silero voices-chatterbox test-chatterbox push-all hydrate load-test help: @echo "🦝 Raccoon TTS Mission - Development Commands" @@ -38,6 +38,10 @@ help: @echo " make voices-chatterbox - Download Chatterbox models" @echo " make test-chatterbox - Test Chatterbox TTS with emotion control" @echo "" + @echo "Testing:" + @echo " make hydrate - Hydrate all models by testing ALL 227 voices" + @echo " make load-test - Load test with concurrent random voice requests" + @echo "" @echo "Container:" @echo " make start - Start Docker container" @echo " make stop - Stop Docker container" @@ -209,3 +213,88 @@ test-chatterbox: -o /tmp/chatterbox_test.mp3 @echo "✅ Test complete! Playing audio..." @firefox /tmp/chatterbox_test.mp3 || mpv /tmp/chatterbox_test.mp3 || echo "Install firefox or mpv to play audio" + +hydrate: + @echo "🦝 Hydrating all TTS models by testing ALL voices..." + @echo "This will test all 227 voices across 4 engines (Piper, XTTS, Silero, Kokoro)" + @echo "" + @mkdir -p /tmp/hydrate_test + @curl -s http://$(REMOTE_HOST):8000/v1/voices | jq -r '.data[] as $$model | $$model.voices[] | "\($$model.id):\(.)"' > /tmp/hydrate_voices.txt + @TOTAL=$$(wc -l < /tmp/hydrate_voices.txt); \ + COUNT=0; \ + FAILED=0; \ + START_TIME=$$(date +%s); \ + while IFS=: read -r MODEL VOICE; do \ + COUNT=$$((COUNT + 1)); \ + printf "[%3d/%3d] Testing %-20s %-30s ... " "$$COUNT" "$$TOTAL" "$$MODEL" "$$VOICE"; \ + if curl -s -X POST http://$(REMOTE_HOST):8000/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d "{\"voice\":\"$$VOICE\",\"input\":\"Hydration test\"}" \ + -o /tmp/hydrate_test/$${MODEL}_$${VOICE}.mp3 2>&1 | grep -q "error"; then \ + echo "❌ FAILED"; \ + FAILED=$$((FAILED + 1)); \ + else \ + SIZE=$$(stat -c%s /tmp/hydrate_test/$${MODEL}_$${VOICE}.mp3 2>/dev/null || echo 0); \ + if [ "$$SIZE" -gt 1000 ]; then \ + echo "✅ OK ($${SIZE} bytes)"; \ + else \ + echo "⚠️ SMALL ($${SIZE} bytes)"; \ + FAILED=$$((FAILED + 1)); \ + fi; \ + fi; \ + done < /tmp/hydrate_voices.txt; \ + END_TIME=$$(date +%s); \ + DURATION=$$((END_TIME - START_TIME)); \ + echo ""; \ + echo "🎉 Hydration complete!"; \ + echo " Total voices: $$TOTAL"; \ + echo " Successful: $$((TOTAL - FAILED))"; \ + echo " Failed: $$FAILED"; \ + echo " Duration: $${DURATION}s"; \ + echo " Output: /tmp/hydrate_test/" + +load-test: + @echo "🚀 Load testing TTS service with random concurrent requests..." + @echo "This will send 100 concurrent requests with random voices across all models" + @echo "" + @mkdir -p /tmp/load_test + @curl -s http://$(REMOTE_HOST):8000/v1/voices | jq -r '.data[] as $$model | $$model.voices[] | "\($$model.id):\(.)"' > /tmp/load_test_voices.txt + @TOTAL_VOICES=$$(wc -l < /tmp/load_test_voices.txt); \ + REQUESTS=100; \ + CONCURRENT=10; \ + echo "Available voices: $$TOTAL_VOICES"; \ + echo "Total requests: $$REQUESTS"; \ + echo "Concurrent: $$CONCURRENT"; \ + echo ""; \ + START_TIME=$$(date +%s); \ + seq 1 $$REQUESTS | xargs -P$$CONCURRENT -I{} bash -c ' \ + LINE=$$((RANDOM % $(TOTAL_VOICES) + 1)); \ + VOICE_SPEC=$$(sed -n "$${LINE}p" /tmp/load_test_voices.txt); \ + MODEL=$$(echo $$VOICE_SPEC | cut -d: -f1); \ + VOICE=$$(echo $$VOICE_SPEC | cut -d: -f2); \ + NUM={}; \ + START=$$(date +%s%3N); \ + if curl -s -X POST http://$(REMOTE_HOST):8000/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d "{\"voice\":\"$$VOICE\",\"input\":\"Load test number $$NUM\"}" \ + -o /tmp/load_test/request_$${NUM}.mp3 2>&1; then \ + END=$$(date +%s%3N); \ + DURATION=$$((END - START)); \ + SIZE=$$(stat -c%s /tmp/load_test/request_$${NUM}.mp3 2>/dev/null || echo 0); \ + printf "[%3d] %-20s %-25s %5dms %6d bytes\n" "$$NUM" "$$MODEL" "$$VOICE" "$$DURATION" "$$SIZE"; \ + else \ + printf "[%3d] %-20s %-25s FAILED\n" "$$NUM" "$$MODEL" "$$VOICE"; \ + fi \ + '; \ + END_TIME=$$(date +%s); \ + DURATION=$$((END_TIME - START_TIME)); \ + SUCCESS=$$(ls /tmp/load_test/*.mp3 2>/dev/null | wc -l); \ + echo ""; \ + echo "🎉 Load test complete!"; \ + echo " Total requests: $$REQUESTS"; \ + echo " Successful: $$SUCCESS"; \ + echo " Failed: $$((REQUESTS - SUCCESS))"; \ + echo " Duration: $${DURATION}s"; \ + echo " Avg: $$((DURATION * 1000 / REQUESTS))ms per request"; \ + echo " Throughput: $$((REQUESTS / DURATION)) req/s"; \ + echo " Output: /tmp/load_test/" diff --git a/speech.py b/speech.py index 85c004d..4695055 100755 --- a/speech.py +++ b/speech.py @@ -46,6 +46,10 @@ voice_to_model_cache = {} # Cached voice data for /v1/voices endpoint (loaded at startup) voices_cache = None +# Semaphores to limit concurrent model loading (prevent thread pool exhaustion) +silero_load_semaphore = asyncio.Semaphore(1) # Only one Silero model load at a time +kokoro_load_semaphore = asyncio.Semaphore(1) # Only one Kokoro model load at a time + def unload_model(): import torch, gc global xtts @@ -658,10 +662,14 @@ async def generate_speech(request: GenerateSpeechRequest): # Load Silero model if not already loaded or if language/speaker changed if silero_model is None or silero_speakers.get('current') != model_key: - logger.info(f"Loading/switching Silero model to {language}/{silero_speaker_key}") - # Run blocking model initialization in thread pool to avoid blocking event loop - silero_model = await asyncio.to_thread(silero_wrapper, language=language, speaker=silero_speaker_key, device='cpu') - silero_speakers['current'] = model_key + # Use semaphore to prevent multiple simultaneous model loads + async with silero_load_semaphore: + # Double-check after acquiring lock (another request may have loaded it) + if silero_model is None or silero_speakers.get('current') != model_key: + logger.info(f"Loading/switching Silero model to {language}/{silero_speaker_key}") + # Run blocking model initialization in thread pool to avoid blocking event loop + silero_model = await asyncio.to_thread(silero_wrapper, language=language, speaker=silero_speaker_key, device='cpu') + silero_speakers['current'] = model_key # Generate audio (also blocking, so run in thread pool) audio_data = await asyncio.to_thread(silero_model.tts, input_text, speaker_id=speaker_id) @@ -809,4 +817,6 @@ if __name__ == "__main__": app.register_model('tts-1-silero') app.register_model('tts-1-kokoro') - uvicorn.run(app, host=args.host, port=args.port) + # Use multiple workers for true concurrency (each worker = separate process with own GIL) + # This prevents thread pool exhaustion and allows concurrent model loading + uvicorn.run(app, host=args.host, port=args.port, workers=4, timeout_keep_alive=300)