feat: implement headless audio player with IPC control
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user