5fab94c06c
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
222 lines
7.6 KiB
Python
222 lines
7.6 KiB
Python
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 = 50.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)
|
|
|
|
def build_graph(template_frame):
|
|
graph = av.filter.Graph()
|
|
src = graph.add_abuffer(template=template_frame)
|
|
loudnorm = graph.add("loudnorm", "I=-16:TP=-1.5:LRA=11")
|
|
sink = graph.add("abuffersink")
|
|
src.link_to(loudnorm)
|
|
loudnorm.link_to(sink)
|
|
graph.configure()
|
|
return graph
|
|
|
|
graph = None
|
|
|
|
with open(fifo_path, 'wb') as fifo:
|
|
iterator = container.decode(stream)
|
|
while state.running:
|
|
with state.lock:
|
|
paused = state.paused
|
|
seek_req = 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)
|
|
with state.lock:
|
|
state.seek_request = None
|
|
iterator = container.decode(stream)
|
|
graph = None # Reset filter graph on seek
|
|
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)
|
|
|
|
if graph is None:
|
|
try:
|
|
graph = build_graph(frame)
|
|
except Exception as e:
|
|
print(f"Filter graph error: {e}", file=sys.stderr)
|
|
graph = "FAILED"
|
|
|
|
if graph == "FAILED":
|
|
frames_to_process = [frame]
|
|
else:
|
|
try:
|
|
graph.push(frame)
|
|
except av.AVError:
|
|
continue
|
|
|
|
frames_to_process = []
|
|
while True:
|
|
try:
|
|
frames_to_process.append(graph.pull())
|
|
except (av.AVError, BlockingIOError):
|
|
break
|
|
|
|
for filtered_frame in frames_to_process:
|
|
resampled_frames = resampler.resample(filtered_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)
|
|
|
|
try:
|
|
fifo.write(arr.tobytes())
|
|
except BrokenPipeError:
|
|
state.running = False
|
|
return
|
|
|
|
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":
|
|
# Optimistically return seek position to prevent playback bar UI lag
|
|
resp["data"] = state.seek_request or 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"
|
|
elif cmd[0] == "quit":
|
|
with state.lock:
|
|
state.running = False
|
|
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)
|
|
# Keep the main thread alive until Navidrome explicitly sends 'quit'
|
|
while state.running:
|
|
time.sleep(0.1)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
state.running = False
|
|
ipc_thread.join(timeout=2.0)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|