From d020b41b432488d4601807accfbadbdd27c3a8e2 Mon Sep 17 00:00:00 2001 From: Tanner Date: Tue, 28 Jul 2026 15:59:24 -0600 Subject: [PATCH] feat: implement headless audio player with IPC control Co-authored-by: aider (gemini/gemini-3.1-pro-preview) --- main.py | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/main.py b/main.py index e69de29..20fef9f 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,174 @@ +import os +import sys +import json +import socket +import threading +import argparse +import time +import numpy as np +import av + +class PlayerState: + def __init__(self): + self.paused = True + self.volume = 100.0 + self.time_pos = 0.0 + self.seek_request = None + self.running = True + self.lock = threading.Lock() + +def playback_thread(file_path, state): + fifo_path = '/tmp/snapfifo' + if not os.path.exists(fifo_path): + os.mkfifo(fifo_path) + + container = av.open(file_path) + stream = container.streams.audio[0] + resampler = av.AudioResampler(format='s16', layout='stereo', rate=48000) + + with open(fifo_path, 'wb') as fifo: + iterator = container.decode(stream) + while state.running: + with state.lock: + paused = state.paused + seek_req = self.seek_request if hasattr(self, 'seek_request') else state.seek_request + vol = state.volume + + if seek_req is not None: + # PyAV seek takes time in AV_TIME_BASE (microseconds) + target_timestamp = int(seek_req * av.time_base) + container.seek(target_timestamp, any_frame=False, backward=True, stream=stream) + with state.lock: + state.seek_request = None + iterator = container.decode(stream) + continue + + if paused: + time.sleep(0.05) + continue + + try: + frame = next(iterator) + except StopIteration: + break + except av.AVError: + continue + + with state.lock: + state.time_pos = float(frame.pts * stream.time_base) + + resampled_frames = resampler.resample(frame) + for r_frame in resampled_frames: + arr = r_frame.to_ndarray() + + # Apply volume + if vol != 100.0: + multiplier = max(0.0, vol / 100.0) + arr = arr.astype(np.float32) * multiplier + arr = np.clip(arr, -32768, 32767).astype(np.int16) + + fifo.write(arr.tobytes()) + +def handle_ipc_client(conn, state): + buffer = "" + while state.running: + try: + data = conn.recv(4096).decode('utf-8') + if not data: + break + buffer += data + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line: + continue + + try: + req = json.loads(line) + cmd = req.get("command", []) + req_id = req.get("request_id", 0) + + resp = {"error": "success", "data": None, "request_id": req_id} + + if not cmd: + resp["error"] = "invalid command" + elif cmd[0] == "get_property": + prop = cmd[1] + with state.lock: + if prop == "pause": + resp["data"] = state.paused + elif prop == "volume": + resp["data"] = state.volume + elif prop == "time-pos": + resp["data"] = state.time_pos + else: + resp["error"] = "property not found" + elif cmd[0] == "set_property": + prop = cmd[1] + val = cmd[2] + with state.lock: + if prop == "pause": + state.paused = bool(val) + elif prop == "volume": + state.volume = float(val) + elif prop == "time-pos": + state.seek_request = float(val) + else: + resp["error"] = "property not found" + else: + resp["error"] = "unknown command" + + conn.sendall((json.dumps(resp) + '\n').encode('utf-8')) + except Exception as e: + err_resp = {"error": str(e)} + conn.sendall((json.dumps(err_resp) + '\n').encode('utf-8')) + except Exception: + break + conn.close() + +def ipc_server_thread(socket_path, state): + if os.path.exists(socket_path): + os.remove(socket_path) + + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(socket_path) + server.listen(5) + + while state.running: + try: + server.settimeout(1.0) + conn, _ = server.accept() + t = threading.Thread(target=handle_ipc_client, args=(conn, state)) + t.daemon = True + t.start() + except socket.timeout: + continue + except Exception: + break + + server.close() + if os.path.exists(socket_path): + os.remove(socket_path) + +def main(): + parser = argparse.ArgumentParser(description="Headless audio player") + parser.add_argument("--input-ipc-server", required=True, help="Path to IPC socket") + parser.add_argument("file", help="Audio file to play") + args = parser.parse_args() + + state = PlayerState() + + ipc_thread = threading.Thread(target=ipc_server_thread, args=(args.input_ipc_server, state)) + ipc_thread.daemon = True + ipc_thread.start() + + try: + playback_thread(args.file, state) + except KeyboardInterrupt: + pass + finally: + state.running = False + ipc_thread.join(timeout=2.0) + +if __name__ == "__main__": + main()