diff --git a/cache/instrumental_ids.json b/cache/instrumental_ids.json index 789de6d..a698584 100644 --- a/cache/instrumental_ids.json +++ b/cache/instrumental_ids.json @@ -1,4 +1,28 @@ [ - "CCAAefnX5vo", - "vzGpVce0Puk" + "1MwfBYzvB2A", + "5BdVtbwQnHs", + "5Pju9ITqLaY", + "Hk-9QNAD6V4", + "Ljl4aFILsOw", + "LqzHTDma900", + "M5n_-8IYDBk", + "OOgnJKo13Hs", + "Vyw3I1Kqets", + "ZaIxE5leHug", + "acktYyOGG6w", + "dALKtBPDd_E", + "eMxGu_Zk3OI", + "frGAB2aVHJA", + "iqyxMTAqr5Q", + "kQfdo-ghfMY", + "kjEFOaGnYcU", + "lH4jPkFcj3k", + "nPv3Evsc4jo", + "nxJeXwN0jiM", + "oLixTqKVPfo", + "q8ofnoWFpx4", + "qCcZRavCOyc", + "uIdUC83QOpg", + "yixWRocskp0", + "ykS7VDAiFAc" ] \ No newline at end of file diff --git a/cache/praise_ids.json b/cache/praise_ids.json index 26f3699..ede174d 100644 --- a/cache/praise_ids.json +++ b/cache/praise_ids.json @@ -2,8 +2,10 @@ "0jft6MfWN6k", "3fZDS_5Jv5E", "5NZFgqoWMq0", + "5aHJSSnjlhk", "BF5pNOZF934", "RwpTUmzz_oY", + "TlZCx3NTRMA", "UIzIfEMpFV0", "WYcpL6otp_Y", "YwsTqkfOpcg", @@ -11,6 +13,7 @@ "ds2H1hbm6xM", "f2oxGYpuLkw", "hVcUqZ3KryA", + "klPEGFmSyUo", "qOD9M95_fS0", "vh70Rbmcg8g", "yNQp9toTAGo", diff --git a/config.yaml b/config.yaml index f1505a7..3ec3c00 100644 --- a/config.yaml +++ b/config.yaml @@ -1,5 +1,6 @@ zones: sanctuary: + active: true jack_input: "sanctuary" ip: "10.40.0.100" port: 5004 @@ -7,6 +8,7 @@ zones: right: "sanctuary:in_r" prayer: + active: true jack_input: "prayer" ip: "10.40.0.101" port: 5004 @@ -14,6 +16,7 @@ zones: right: "prayer:in_r" office: + active: false jack_input: "office" ip: "10.40.0.102" port: 5004 @@ -21,6 +24,7 @@ zones: right: "office:in_r" fellowship: + active: false jack_input: "fellowship" ip: "10.40.0.103" port: 5004 @@ -28,6 +32,7 @@ zones: right: "fellowship:in_r" Macbook: + active: true jack_input: "Mac" ip: "192.168.2.1" port: 5004 @@ -35,17 +40,17 @@ zones: right: "Mac:in_r" sources: - praise: + Praise: left: "Praise:left" right: "Praise:right" youtube_url: "https://youtube.com/playlist?list=PLJjON0cPmpcvJaax7cNGjYJeSGCPKHxuF&si=cEfJhukvRhrul2oW" id_cache: "/home/media/Scripts/ZonePlayer/cache/praise_ids.json" - worship: + Worship: left: "Worship:left" right: "Worship:right" youtube_url: "https://youtube.com/playlist?list=PLJjON0cPmpctXc5x8--6lZDKjrprVvTkd&si=0El4d6t2lgtVYS69" id_cache: "/home/media/Scripts/ZonePlayer/cache/worship_ids.json" - instrumental: + Instrumental: left: "Instrumental:left" right: "Instrumental:right" youtube_url: "https://youtube.com/playlist?list=PLJjON0cPmpcuJwDtp6OczGJADZB4HYrAm&si=vtu-vQnY3wIgDzYB" diff --git a/jack_mp3_player.py b/jack_mp3_player.py index 05a95d3..f72e9b7 100644 --- a/jack_mp3_player.py +++ b/jack_mp3_player.py @@ -1,13 +1,14 @@ -import jack # type: ignore +import jack import time -import numpy as np # type: ignore -from pydub import AudioSegment # type: ignore +import numpy as np +from pydub import AudioSegment import random import sys import json import os -import paho.mqtt.client as mqtt # type: ignore -from paho.mqtt.client import Client # type: ignore +import yaml +import paho.mqtt.client as mqtt +import threading import re # === load and shuffle .m3u playlist === @@ -22,7 +23,7 @@ def decode_mp3(path): song = AudioSegment.from_file(path) song = song.set_frame_rate(48000).set_channels(2).set_sample_width(4) samples = np.array(song.get_array_of_samples()).astype(np.float32) / (2**31) - return samples.reshape((-1,2)) #stereo:L R + return samples.reshape((-1, 2)) # stereo: L R def update_now_playing(playlist_name, song_title): path = "/home/media/Music/now_playing.json" @@ -38,8 +39,7 @@ def update_now_playing(playlist_name, song_title): # MQTT title broadcast mqtt_topic = f"music/now_playing/{playlist_name.lower()}" - mqtt_payload = os.path.basename(song_title) - mqtt_payload = os.path.splitext(mqtt_payload)[0] + mqtt_payload = os.path.splitext(os.path.basename(song_title))[0] mqtt_payload = re.sub(r'\s*\[[^\]]+\]$', '', mqtt_payload) try: @@ -53,60 +53,73 @@ def update_now_playing(playlist_name, song_title): except Exception as e: print(f"[MQTT] failed to publish: {e}") -# === Get playlist name from CLI === -if len(sys.argv) < 2: - print ("Usage: python3 jack_mp3_player.py Praise|Worship|Instrumental") - sys.exit(1) +# === YAML config === +def load_config(): + with open("config.yaml") as f: + return yaml.safe_load(f) -playlist_name = sys.argv[1] -playlist_file = f"/home/media/Music/{playlist_name}.m3u" +# === JACK + player logic === +def run_playlist(playlist_name, playlist_file): + client = jack.Client(playlist_name) + out_l = client.outports.register("left") + out_r = client.outports.register("right") -# === jack setup === -client = jack.Client(playlist_name) -out_l = client.outports.register("left") -out_r = client.outports.register("right") + playlist = load_playlist(playlist_file) + current_index = 0 + samples = decode_mp3(playlist[current_index]) + buffer_pos = [0] -playlist = load_playlist(playlist_file) -current_index = 0 -samples = decode_mp3(playlist[current_index]) -buffer_pos = [0] - -@client.set_process_callback -def process(frames): - bufsize = frames - end = buffer_pos[0] + bufsize - - #zero fills if out of data - if buffer_pos[0] >= len(samples): - out_l.get_buffer()[:] = np.zeros(bufsize, dtype=np.float32) - out_r.get_buffer()[:] = np.zeros(bufsize, dtype=np.float32) - return - - chunk = samples[buffer_pos[0]:end] - #pad if last chunk - if len(chunk) < bufsize: - pad = np.zeros((bufsize - len(chunk), 2), dtype=np.float32) - chunk = np.vstack((chunk, pad)) - - out_l.get_buffer()[:] = chunk[:, 0].copy() - out_r.get_buffer()[:] = chunk[:, 1].copy() - buffer_pos[0] += bufsize - -# === Activate jack === -with client: - print(f"Playing playlist: {playlist_name}") - print(f"Playing song: {playlist[current_index]}") - update_now_playing(playlist_name, playlist[current_index]) - while True: + @client.set_process_callback + def process(frames): + bufsize = frames + end = buffer_pos[0] + bufsize if buffer_pos[0] >= len(samples): - current_index += 1 - if current_index >= len(playlist): - playlist = load_playlist(playlist_file) - current_index = 0 + out_l.get_buffer()[:] = np.zeros(bufsize, dtype=np.float32) + out_r.get_buffer()[:] = np.zeros(bufsize, dtype=np.float32) + return + chunk = samples[buffer_pos[0]:end] + if len(chunk) < bufsize: + pad = np.zeros((bufsize - len(chunk), 2), dtype=np.float32) + chunk = np.vstack((chunk, pad)) + out_l.get_buffer()[:] = chunk[:, 0].copy() + out_r.get_buffer()[:] = chunk[:, 1].copy() + buffer_pos[0] += bufsize - print(f"Playing: {playlist[current_index]}") - update_now_playing(playlist_name, playlist[current_index]) - samples = decode_mp3(playlist[current_index]) - buffer_pos[0] = 0 - - time.sleep(0.1) + with client: + print(f"[{playlist_name}] Playing: {playlist[current_index]}") + update_now_playing(playlist_name, playlist[current_index]) + while True: + if buffer_pos[0] >= len(samples): + current_index += 1 + if current_index >= len(playlist): + playlist = load_playlist(playlist_file) + current_index = 0 + print(f"[{playlist_name}] Playing: {playlist[current_index]}") + update_now_playing(playlist_name, playlist[current_index]) + samples = decode_mp3(playlist[current_index]) + buffer_pos[0] = 0 + time.sleep(0.1) + +# === Main logic === +if __name__ == "__main__": + config = load_config() + sources = config.get("sources", {}) + + if len(sys.argv) > 1: + playlist_name = sys.argv[1] + if playlist_name in sources and sources[playlist_name].get("active", True): + playlist_file = f"/home/media/Music/{playlist_name}.m3u" + run_playlist(playlist_name, playlist_file) + else: + print(f"[!] Source '{playlist_name}' not found or not active.") + else: + threads = [] + for playlist_name, source_data in sources.items(): + if source_data.get("active", True): + playlist_file = f"/home/media/Music/{playlist_name}.m3u" + t = threading.Thread(target=run_playlist, args=(playlist_name, playlist_file), daemon=True) + t.start() + threads.append(t) + + for t in threads: + t.join() diff --git a/router.py b/router.py index efe0f80..f884535 100644 --- a/router.py +++ b/router.py @@ -11,7 +11,7 @@ with open("config.yaml", "r") as f: zones = config.get("zones", {}) sources = config.get("sources", {}) -DEFAULT_SOURCE = "worship" +DEFAULT_SOURCE = "Worship" print("Zones loaded:", zones.keys()) print("Sources loaded:", sources.keys()) @@ -73,7 +73,38 @@ def on_connect(client, userdata, flags, rc): client.subscribe(topic) print(f"subscribed to {topic}") -time.sleep(1) +def get_required_jack_ports(): + required = [] + + # From sources + for source_name, source_data in sources.items(): + if source_data.get("active", True): + required.append(source_data["left"]) + required.append(source_data["right"]) + + # From zones + for zone_name, zone_data in zones.items(): + if zone_data.get("active", True): + required.append(zone_data["left"]) + required.append(zone_data["right"]) + + return required + +def wait_for_required_jack_ports(required_ports, interval=5): + print("[router] Waiting for all required JACK ports to appear...") + while True: + available = set(p.name for p in client_jack.get_ports()) + missing = [p for p in required_ports if p not in available] + if not missing: + print("[router] All required JACK ports found. Proceeding.") + return + print(f"[router] Still waiting for: {missing}") + time.sleep(interval) + + +required_ports = get_required_jack_ports() +wait_for_required_jack_ports(required_ports) + active_zones = wait_for_available_zone_ports() apply_default_connections(active_zones) diff --git a/sync.log b/sync.log index 30bd7d6..95fddc1 100644 --- a/sync.log +++ b/sync.log @@ -16,3 +16,8 @@ Saved updated ID cache: /home/media/Scripts/ZonePlayer/cache/instrumental_ids.js ✅ Updated playlist: /home/media/Music/Praise.m3u with 16 songs ✅ Updated playlist: /home/media/Music/Instrumental.m3u with 2 songs ✅ Playlist sync complete. +Traceback (most recent call last): + File "/home/media/Scripts/ZonePlayer/yt_sync.py", line 12, in + with open(CONFIG_FILE, "r") as f: + ^^^^^^^^^^^^^^^^^^^^^^ +FileNotFoundError: [Errno 2] No such file or directory: 'config.yaml' diff --git a/wait_for_ports.py b/wait_for_ports.py new file mode 100755 index 0000000..41499d2 --- /dev/null +++ b/wait_for_ports.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import jack +import time +import sys +import subprocess + +expected = { + "Praise:left", "Praise:right", + "Worship:left", "Worship:right", + "Instrumental:left", "Instrumental:right", + "sanctuary:in_l", "sanctuary:in_r", + "prayer:in_l", "prayer:in_r", + "Mac:in_l", "Mac:in_r" +} + +client = jack.Client("waiter") +timeout = 15 +deadline = time.time() + timeout + +while time.time() < deadline: + current = {port.name for port in client.get_ports()} + if expected.issubset(current): + print("All JACK ports found.") + subprocess.run(["systemctl", "--system", "restart", "router.service"]) + sys.exit(0) + time.sleep(0.5) + +print("Timeout waiting for JACK ports.") +sys.exit(1) diff --git a/wait_for_ports.sh b/wait_for_ports.sh new file mode 100755 index 0000000..19e2175 --- /dev/null +++ b/wait_for_ports.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +REQUIRED_PORTS=( + "sanctuary_out:left" + "sanctuary_out:right" + "prayer_out:left" + "prayer_out:right" + "Macbook_out:left" + "Macbook_out:right" + "Praise:left" + "Praise:right" + "Worship:left" + "Worship:right" + "Instrumental:left" + "Instrumental:right" +) + +DEADLINE=$((SECONDS + 10)) + +while [ $SECONDS -lt $DEADLINE ]; do + ALL_FOUND=true + for PORT in "${REQUIRED_PORTS[@]}"; do + if ! /usr/bin/jack_lsp | grep -q "$PORT"; then + ALL_FOUND=false + break + fi + done + + if $ALL_FOUND; then + echo "All required JACK ports found." + exit 0 + fi + + sleep 0.5 +done + +echo "Timeout waiting for JACK ports" +exit 1 diff --git a/zone.py b/zone.py index 33ef3ae..dbe423a 100644 --- a/zone.py +++ b/zone.py @@ -1,52 +1,67 @@ -import yaml # type: ignore +import yaml # type: ignore import sys -import jack # type: ignore +import jack # type: ignore import socket -import numpy as np # type: ignore +import numpy as np # type: ignore import time +import threading +def load_config(): + with open("config.yaml", "r") as f: + return yaml.safe_load(f) -if len(sys.argv) < 2: - print("Usage: python3 zone.py ") - sys.exit(1) +def run_zone(zone_name: str, zone_config: dict): + jack_input = zone_config["jack_input"] + ip = zone_config["ip"] + port = zone_config["port"] -zone_name = sys.argv[1] + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) -with open("config.yaml", "r") as f: - config = yaml.safe_load(f) + client = jack.Client(jack_input) + client.inports.register("in_l") + client.inports.register("in_r") -zone = config["zones"].get(zone_name) + def send_pcm_frames(frames: np.ndarray): + int16 = (frames * 32767.0).astype(np.int16) + interleaved = np.column_stack(int16).ravel() + sock.sendto(interleaved.tobytes(), (ip, port)) -if not zone: - print(f"Zone '{zone_name} not found in config") - sys.exit(1) + @client.set_process_callback + def process(frames): + l = client.inports[0].get_array() + r = client.inports[1].get_array() + data = np.vstack((l, r)) + send_pcm_frames(data) -jack_input = zone["jack_input"] -ip = zone["ip"] -port = zone["port"] + with client: + print(f"🎧 Streaming {zone_name} to {ip}:{port}") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print(f"🛑 {zone_name} stopped") -sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +if __name__ == "__main__": + config = load_config() + zones = config.get("zones", {}) -client = jack.Client(jack_input) -client.inports.register("in_l") -client.inports.register("in_r") + if len(sys.argv) > 1: + zone_name = sys.argv[1] + zone = zones.get(zone_name) + if not zone: + print(f"[!] Zone '{zone_name}' not found in config") + sys.exit(1) + if not zone.get("active", True): + print(f"[!] Zone '{zone_name}' is inactive in config") + sys.exit(1) + run_zone(zone_name, zone) + else: + threads = [] + for zone_name, zone_config in zones.items(): + if zone_config.get("active", True): + t = threading.Thread(target=run_zone, args=(zone_name, zone_config), daemon=True) + t.start() + threads.append(t) -def send_pcm_frames(frames: np.ndarray): - int16 = (frames * 32767.0).astype(np.int16) - interleaved = np.column_stack(int16).ravel() - sock.sendto(interleaved.tobytes(), (ip, port)) - -@client.set_process_callback -def process(frames): - l = client.inports[0].get_array() - r = client.inports[1].get_array() - data = np.vstack((l, r)) - send_pcm_frames(data) - -with client: - print(f"Streaming {zone_name} to {ip}:{port}") - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - print("Stopped") \ No newline at end of file + for t in threads: + t.join() diff --git a/zoneplayer_launcher.py b/zoneplayer_launcher.py new file mode 100644 index 0000000..d470818 --- /dev/null +++ b/zoneplayer_launcher.py @@ -0,0 +1,47 @@ +import subprocess +import time +import os + +venv_python = "/home/media/Scripts/ZonePlayer/venv/bin/python3" +script_dir = "/home/media/Scripts/ZonePlayer" + +zones = ["sanctuary", "prayer", "office", "fellowship", "Macbook"] +players = ["praise", "worship", "instrumental"] + +logfile = "/tmp/zoneplayer_launcher.log" + +def log(msg): + with open(logfile, "a") as f: + f.write(msg + "\n") + print(msg) + +def start_detached(script, *args): + try: + subprocess.Popen( + [venv_python, os.path.join(script_dir, script), *args], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + log(f"Started: {script} {' '.join(args)}") + except Exception as e: + log(f"ERROR: Failed to start {script} {' '.join(args)} - {e}") + +log("=== ZonePlayer Launcher Starting ===") + +# Launch zones +for zone in zones: + start_detached("zone.py", zone) + +# Launch players +for player in players: + start_detached("jack_mp3_player.py", player) + +log("Sleeping 10 seconds to allow JACK ports to appear") +time.sleep(10) + +# Start router +start_detached("router.py") +log("Router started") + +log("=== ZonePlayer Launcher Done ===")