Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
7.0 KiB
Python
Executable File
168 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Play new SDR voice recordings on the speakers at the Pi's 3.5 mm jack, while switched on.
|
|
|
|
State (on / volume) is written by the radio web UI (web/speaker.py, LAN clients only) to
|
|
/var/lib/sdr-speaker/state.json. Only recordings that appear after the switch was turned on are played,
|
|
one after another; switching off stops the current one at once.
|
|
"""
|
|
import json
|
|
import re
|
|
import os
|
|
import subprocess
|
|
import time
|
|
import urllib.request
|
|
|
|
STATE = os.environ.get('SPEAKER_STATE', '/var/lib/sdr-speaker/state.json')
|
|
RECENT = os.environ.get('SDR_RECENT', 'http://127.0.0.1:8090/api/recent?limit=10')
|
|
CARD = os.environ.get('SPEAKER_CARD', 'Headphones')
|
|
DEVICE = f'plughw:CARD={CARD},DEV=0'
|
|
MAX_QUEUE = 8 # a burst of traffic must not keep the speakers busy for half an hour
|
|
SLEEP = os.environ.get('PI_SLEEP', '/run/pi-sleep.json')
|
|
NOW = os.environ.get('SPEAKER_NOW', '/run/sdr-speaker/now.json') # what is playing, for the Pi's little screen
|
|
|
|
|
|
def publish(playing, queue, on, vol):
|
|
data = {'on': on, 'volume': vol, 'queue': len(queue), 'playing': playing}
|
|
try:
|
|
tmp = NOW + '.tmp'
|
|
with open(tmp, 'w', encoding='utf-8') as fh:
|
|
json.dump(data, fh, ensure_ascii=False)
|
|
os.replace(tmp, NOW)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def state():
|
|
try:
|
|
with open(STATE, encoding='utf-8') as fh:
|
|
st = json.load(fh)
|
|
except (OSError, ValueError):
|
|
st = {}
|
|
on = bool(st.get('on'))
|
|
try: # calendar sleep window (/opt/pi/bin/sleep-window): silent, the switch itself stays as the user left it
|
|
with open(SLEEP, encoding='utf-8') as fh:
|
|
sl = json.load(fh)
|
|
if sl.get('asleep') and time.time() - sl.get('at', 0) < 600:
|
|
on = False
|
|
except (OSError, ValueError):
|
|
pass
|
|
return on, int(st.get('volume', 70))
|
|
|
|
|
|
def recent():
|
|
try:
|
|
with urllib.request.urlopen(RECENT, timeout=5) as r:
|
|
return json.loads(r.read())
|
|
except Exception: # noqa: BLE001
|
|
return None # unreachable (e.g. the radio page still starting): not the same as "no recordings"
|
|
|
|
|
|
def older_than(x, t):
|
|
"""Recording made before t (unix time)? Recordings from before the service started are never played."""
|
|
try:
|
|
from datetime import datetime
|
|
return datetime.fromisoformat(x['ts']).timestamp() < t
|
|
except (KeyError, ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
# The speakers take their power from port 4 of the powered VIA hub (per-port power switching): idle they hiss,
|
|
# so the port is switched off after IDLE_OFF s without playback and back on (with a warm-up) before the next one.
|
|
HUB, HUB_PORT = os.environ.get('SPEAKER_HUB', '1-1'), os.environ.get('SPEAKER_HUB_PORT', '').strip()
|
|
# only a single port number switches anything: an empty/odd value would make uhubctl act on EVERY port
|
|
HUB_PORT = HUB_PORT if re.fullmatch(r'[1-9](,[1-9])*', HUB_PORT) else '' # e.g. "3" or "3,4"
|
|
IDLE_OFF, WARMUP = 30, 1.5
|
|
_power = {'on': None, 'since': 0.0}
|
|
|
|
|
|
def speaker_power(on):
|
|
if not HUB_PORT or _power['on'] == on: # no port configured: power switching is off
|
|
return
|
|
# the speakers only draw power: never switch a port with a USB device on it (the camera was moved onto the
|
|
# speakers' port once, and every pause cut it off)
|
|
busy = [p for p in HUB_PORT.split(',') if os.path.exists(f'/sys/bus/usb/devices/{HUB}.{p}')]
|
|
if busy:
|
|
if _power.get('busy') != busy:
|
|
print(f"speaker power: port {','.join(busy)} has a USB device on it, not switching", flush=True)
|
|
_power['busy'] = busy
|
|
return
|
|
r = subprocess.run(['uhubctl', '-l', HUB, '-p', HUB_PORT, '-a', 'on' if on else 'off'],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
|
if r.returncode == 0:
|
|
_power.update(on=on, since=time.time())
|
|
print(f"speaker power {'on' if on else 'off'}", flush=True)
|
|
try: # for the dashboard (sysinfo-api): /run/sdr-speaker is this unit's RuntimeDirectory
|
|
with open('/run/sdr-speaker/speakers.json', 'w') as fh:
|
|
json.dump({'on': on, 'since': _power['since'], 'port': HUB_PORT}, fh)
|
|
except OSError:
|
|
pass
|
|
if on:
|
|
time.sleep(WARMUP) # let the amplifier wake up before the first word
|
|
|
|
|
|
def set_volume(v):
|
|
subprocess.run(['amixer', '-q', '-c', CARD, 'sset', 'PCM', f'{v}%'], check=False,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
|
|
def main():
|
|
seen, queue, proc, was_on, vol_now, playing = set(), [], None, False, None, None
|
|
started, baseline = time.time(), False
|
|
last_play = 0.0
|
|
speaker_power(False)
|
|
while True:
|
|
on, vol = state()
|
|
if on and vol != vol_now:
|
|
set_volume(vol)
|
|
vol_now = vol
|
|
got = recent()
|
|
if got is None: # the list could not be read: never treat that as an empty history
|
|
time.sleep(1.5)
|
|
continue
|
|
items = [x for x in got if x.get('class') == 'voice']
|
|
if not baseline: # first answer after start (boot): whatever is there already is old, never play it
|
|
seen, baseline, was_on = {x['id'] for x in got}, True, on
|
|
time.sleep(1.5)
|
|
continue
|
|
if on and not was_on:
|
|
seen = {x['id'] for x in items} # switching on never replays what is already there
|
|
queue = []
|
|
elif on:
|
|
for x in reversed(items): # oldest first
|
|
if x['id'] not in seen and not older_than(x, started - 30):
|
|
seen.add(x['id'])
|
|
queue.append(x)
|
|
del queue[:-MAX_QUEUE]
|
|
if not on:
|
|
queue = []
|
|
if proc and proc.poll() is None:
|
|
proc.terminate()
|
|
was_on = on
|
|
if on and queue and (proc is None or proc.poll() is not None):
|
|
speaker_power(True)
|
|
x = queue.pop(0)
|
|
proc = subprocess.Popen(['ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error', '-i', x['url'],
|
|
'-af', 'apad=pad_dur=0.4', '-f', 'alsa', DEVICE],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
print(f"play {x.get('channel')} {x.get('ts')}", flush=True)
|
|
playing = {k: x.get(k) for k in ('channel', 'group', 'ts', 'freq_mhz', 'duration_s')}
|
|
playing['started'] = time.time()
|
|
if proc is None or proc.poll() is not None:
|
|
if proc is not None and playing is None:
|
|
proc = None
|
|
if proc is not None and proc.poll() is not None:
|
|
playing = None
|
|
busy = proc is not None and proc.poll() is None
|
|
if busy:
|
|
last_play = time.time()
|
|
if _power['on'] and not busy and not queue and (not on or time.time() - last_play > IDLE_OFF):
|
|
speaker_power(False)
|
|
publish(playing if on else None, queue, on, vol)
|
|
if len(seen) > 2000:
|
|
seen = {x['id'] for x in items}
|
|
time.sleep(1.5)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|