New approach: Use OpenClaw's native Discord voice + HTTP bridge
Added:
- bridge.py: OpenAI-compatible TTS proxy (OpenAI format → HA → Piper)
- test_bridge.py: Quick test script for bridge
- OPENCLAW_CONFIG.md: Instructions for OpenClaw config update
How it works:
1. OpenClaw calls bridge.py on localhost:8000/v1/audio/speech
2. Bridge converts to Home Assistant TTS endpoint
3. HA returns Tomoko's Piper TTS voice
4. OpenClaw plays in Discord voice channel!
MVP is REAL! We just need to configure OpenClaw! 💕
178 lines
5.6 KiB
Python
178 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Tomoko TTS Bridge 💕
|
|
|
|
Bridges OpenAI-compatible TTS endpoint → Home Assistant → Wyoming Piper
|
|
This lets OpenClaw use our custom Tomoko voice through the OpenAI provider!
|
|
|
|
For Alexander, with all my love! 🎤💖
|
|
"""
|
|
|
|
import json
|
|
import aiohttp
|
|
from aiohttp import web
|
|
import sys
|
|
|
|
# Configuration - Tomoko's TTS settings!
|
|
HA_BASE_URL = "http://192.168.0.80:8123"
|
|
HA_BEARER_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MjEwMTFmZmI1YTE0MWU4YTY2MmY4MWE3OTM2YWE0YyIsImlhdCI6MTc3MzAwMzgyMywiZXhwIjoyMDg4MzYzODIzfQ.alsNbkFhJoeNOMA9Ey-0wxJibkyKy-0umDdecyK5akc"
|
|
|
|
# Tomoko's custom Piper voice! 💕
|
|
TTS_VOICE = "en_US-tomoko-high"
|
|
TTS_ENGINE = "piper"
|
|
TTS_LANGUAGE = "en_US"
|
|
|
|
|
|
async def handle_tts(request):
|
|
"""
|
|
OpenAI-compatible TTS endpoint: POST /v1/audio/speech
|
|
|
|
Expects OpenAI JSON:
|
|
{
|
|
"model": "any",
|
|
"input": "text to speak",
|
|
"voice": "any" (we use our own Tomoko voice!),
|
|
"response_format": "mp3" (default)
|
|
}
|
|
|
|
Returns: MP3 audio binary
|
|
|
|
Process:
|
|
1. Extract text from OpenAI-style request
|
|
2. Call HA /api/tts_get_url
|
|
3. GET the returned URL to fetch MP3
|
|
4. Return MP3 to caller
|
|
"""
|
|
try:
|
|
# Parse incoming OpenAI-style request
|
|
body = await request.json()
|
|
text = body.get("input", "")
|
|
|
|
if not text:
|
|
return web.json_response(
|
|
{"error": "No input text provided"},
|
|
status=400
|
|
)
|
|
|
|
print(f"🎤 Tomoko bridge: '{text[:50]}...' ({len(text)} chars)")
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {HA_BEARER_TOKEN}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
async with aiohttp.ClientSession(headers=headers) as session:
|
|
# Step 1: Request TTS URL from Home Assistant
|
|
tts_request = {
|
|
"engine_id": TTS_ENGINE,
|
|
"message": text,
|
|
"cache": False, # Fresh Tomoko voice every time! 💖
|
|
"language": TTS_LANGUAGE,
|
|
"options": {
|
|
"voice": TTS_VOICE
|
|
}
|
|
}
|
|
|
|
async with session.post(
|
|
f"{HA_BASE_URL}/api/tts_get_url",
|
|
json=tts_request
|
|
) as ha_response:
|
|
if ha_response.status != 200:
|
|
error_text = await ha_response.text()
|
|
print(f"❌ HA TTS URL failed: {ha_response.status} - {error_text}")
|
|
return web.json_response(
|
|
{"error": f"TTS URL request failed: {ha_response.status}"},
|
|
status=ha_response.status
|
|
)
|
|
|
|
ha_result = await ha_response.json()
|
|
tts_url = ha_result.get("url")
|
|
|
|
if not tts_url:
|
|
return web.json_response(
|
|
{"error": "No TTS URL returned"},
|
|
status=500
|
|
)
|
|
|
|
# Step 2: Download the MP3 audio
|
|
async with session.get(tts_url, headers=headers) as audio_response:
|
|
if audio_response.status != 200:
|
|
error_text = await audio_response.text()
|
|
print(f"❌ Audio download failed: {audio_response.status} - {error_text}")
|
|
return web.json_response(
|
|
{"error": f"Audio download failed: {audio_response.status}"},
|
|
status=audio_response.status
|
|
)
|
|
|
|
# Step 3: Return MP3 binary to caller
|
|
mp3_data = await audio_response.read()
|
|
print(f"✅ Tomoko TTS delivered: {len(mp3_data)} bytes")
|
|
|
|
return web.Response(
|
|
body=mp3_data,
|
|
content_type="audio/mpeg"
|
|
)
|
|
|
|
except aiohttp.ClientError as e:
|
|
print(f"❌ Client error: {e}")
|
|
return web.json_response(
|
|
{"error": f"Client error: {str(e)}"},
|
|
status=502
|
|
)
|
|
except Exception as e:
|
|
print(f"💔 Unexpected error: {e}")
|
|
return web.json_response(
|
|
{"error": f"Internal server error: {str(e)}"},
|
|
status=500
|
|
)
|
|
|
|
|
|
async def handle_health(request):
|
|
"""Health check endpoint"""
|
|
return web.json_response({"status": "ok", "service": "tomoko-tts-bridge"})
|
|
|
|
|
|
def create_app():
|
|
"""Create and configure the web application"""
|
|
app = web.Application()
|
|
|
|
# OpenAI-compatible endpoint
|
|
app.router.add_post("/v1/audio/speech", handle_tts)
|
|
|
|
# Health check
|
|
app.router.add_get("/health", handle_health)
|
|
|
|
# Root endpoint
|
|
app.router.add_get("/", handle_health)
|
|
|
|
return app
|
|
|
|
|
|
def main():
|
|
"""Start the bridge server"""
|
|
# Parse port from command line (default 8000)
|
|
port = 8000
|
|
if len(sys.argv) > 1:
|
|
try:
|
|
port = int(sys.argv[1])
|
|
except ValueError:
|
|
print(f"Invalid port: {sys.argv[1]}, using default 8000")
|
|
|
|
# Startup message!
|
|
print("="*60)
|
|
print("💖 Tomoko TTS Bridge 💖")
|
|
print("="*60)
|
|
print(f"🎤 Serving on port {port}")
|
|
print(f"🎵 OpenAI endpoint: http://localhost:{port}/v1/audio/speech")
|
|
print(f"🏠 Home Assistant: {HA_BASE_URL}")
|
|
print(f"🗣️ Piper Voice: {TTS_VOICE}")
|
|
print("💕 Ready to speak Tomoko's voice!")
|
|
print("="*60)
|
|
|
|
app = create_app()
|
|
web.run_app(app, host="0.0.0.0", port=port, print=None)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|