/ services / announce-player / player.py
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())