player.py
1 """Bob Announcement Player — satellite audio playback service. 2 3 Runs on room devices (MacBook, RPi, etc.). Subscribes to NATS for 4 voice announcements, fetches TTS audio from Fish Speech on rig.lan, 5 and plays through local speakers. 6 7 Install on satellite: pip3 install nats-py 8 Run: python3 player.py 9 """ 10 11 import asyncio 12 import json 13 import os 14 import struct 15 import subprocess 16 import sys 17 import tempfile 18 import time 19 import urllib.request 20 import urllib.error 21 22 NATS_URL = os.getenv("NATS_URL", "nats://rig.lan:4222") 23 TTS_URL = os.getenv("TTS_URL", "http://rig.lan:10400") # Kokoro TTS (fast) 24 TTS_VOICE = os.getenv("TTS_VOICE", "bf_emma") 25 TTS_SPEED = float(os.getenv("TTS_SPEED", "1.0")) 26 PLAYER_CMD = os.getenv("PLAYER_CMD", "paplay") # paplay, aplay, mpv, ffplay 27 VOLUME = float(os.getenv("VOLUME", "0.8")) 28 DEVICE_NAME = os.getenv("DEVICE_NAME", "greatroom") 29 30 31 def fetch_tts_audio(text: str) -> bytes: 32 """Generate speech audio from Kokoro TTS (fast, OpenAI-compatible).""" 33 payload = { 34 "model": "kokoro", 35 "input": text, 36 "voice": TTS_VOICE, 37 "response_format": "wav", 38 "speed": TTS_SPEED, 39 } 40 41 data = json.dumps(payload).encode() 42 req = urllib.request.Request( 43 f"{TTS_URL}/v1/audio/speech", 44 data=data, 45 headers={"Content-Type": "application/json"}, 46 method="POST", 47 ) 48 49 try: 50 with urllib.request.urlopen(req, timeout=30) as resp: 51 return resp.read() 52 except Exception as e: 53 print(f"TTS error: {e}", file=sys.stderr) 54 return b"" 55 56 57 def play_audio(wav_data: bytes): 58 """Play WAV audio through local speakers.""" 59 if not wav_data: 60 print("No audio to play", file=sys.stderr) 61 return 62 63 with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: 64 f.write(wav_data) 65 tmpfile = f.name 66 67 try: 68 if PLAYER_CMD == "paplay": 69 subprocess.run( 70 ["paplay", tmpfile], 71 timeout=120, 72 check=True, 73 ) 74 elif PLAYER_CMD == "aplay": 75 subprocess.run( 76 ["aplay", tmpfile], 77 timeout=120, 78 check=True, 79 ) 80 elif PLAYER_CMD == "mpv": 81 subprocess.run( 82 ["mpv", "--no-video", f"--volume={int(VOLUME*100)}", tmpfile], 83 timeout=120, 84 check=True, 85 ) 86 else: 87 subprocess.run( 88 [PLAYER_CMD, tmpfile], 89 timeout=120, 90 check=True, 91 ) 92 except subprocess.TimeoutExpired: 93 print("Audio playback timed out", file=sys.stderr) 94 except Exception as e: 95 print(f"Playback error: {e}", file=sys.stderr) 96 finally: 97 os.unlink(tmpfile) 98 99 100 async def main(): 101 try: 102 import nats 103 except ImportError: 104 print("Installing nats-py...") 105 subprocess.run([sys.executable, "-m", "pip", "install", "--user", "nats-py"], check=True) 106 import nats 107 108 nc = await nats.connect(NATS_URL) 109 js = nc.jetstream() 110 111 # Ensure announce subjects are in the stream 112 try: 113 await js.update_stream( 114 name="BOB_AGENTS", 115 subjects=["bob.agent.>", "bob.calendar.>", "bob.alert.>", "bob.announce.>"], 116 ) 117 except Exception: 118 pass 119 120 print(f"Bob Announcement Player ({DEVICE_NAME})") 121 print(f" NATS: {NATS_URL}") 122 print(f" TTS: {TTS_URL}") 123 print(f" Player: {PLAYER_CMD}") 124 print(f" Listening for announcements on bob.announce.voice...") 125 126 async def handle_announcement(msg): 127 try: 128 data = json.loads(msg.data.decode()) 129 text = data.get("text", "") 130 target = data.get("target", "all") 131 132 # Check if this announcement is for us 133 if target not in ("all", DEVICE_NAME): 134 return 135 136 print(f"\nAnnouncement received ({len(text)} chars)") 137 print(f" Text: {text[:100]}...") 138 139 # Fetch TTS audio 140 print(" Generating speech...") 141 t0 = time.time() 142 wav_data = fetch_tts_audio(text) 143 print(f" TTS: {len(wav_data)} bytes in {time.time()-t0:.1f}s") 144 145 # Play it 146 if wav_data: 147 print(" Playing...") 148 play_audio(wav_data) 149 print(" Done.") 150 151 await msg.ack() 152 except Exception as e: 153 print(f"Error handling announcement: {e}", file=sys.stderr) 154 155 await js.subscribe( 156 "bob.announce.voice", 157 cb=handle_announcement, 158 durable=f"announce_player_{DEVICE_NAME}", 159 deliver_policy="new", 160 manual_ack=True, 161 ) 162 163 print("Ready.\n") 164 165 try: 166 while True: 167 await asyncio.sleep(1) 168 except (KeyboardInterrupt, asyncio.CancelledError): 169 pass 170 finally: 171 await nc.close() 172 print("Player stopped.") 173 174 175 if __name__ == "__main__": 176 asyncio.run(main())