Compare commits
6 Commits
master
...
47a953062a
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a953062a | |||
| 963c72fccc | |||
| 844e57fc93 | |||
| 5fab94c06c | |||
| c100dada93 | |||
| 4b0774b3fa |
@@ -0,0 +1,24 @@
|
||||
# Qotplayer
|
||||
|
||||
A lightweight, headless Python audio player designed as a fast drop-in replacement for `mpv`.
|
||||
|
||||
## What it does
|
||||
- Decodes and resamples common audio formats to `48000Hz`, `stereo`, `s16` PCM.
|
||||
- Applies on-the-fly EBU R128 volume normalization using FFmpeg's `loudnorm` filter.
|
||||
- Outputs raw PCM audio directly to a FIFO pipe (`/tmp/snapfifo`).
|
||||
- Provides a JSON IPC server over a Unix socket for real-time playback control (pause, volume, seek, quit).
|
||||
|
||||
## What it's for
|
||||
This player is specifically built to act as a backend player for **Navidrome** (in Jukebox mode) feeding into multi-room audio systems like **Snapcast**. It avoids the overhead of full `mpv` while maintaining compatibility with its IPC protocol.
|
||||
|
||||
## How to use it
|
||||
|
||||
1. Install the required dependencies (Python 3.11+ recommended):
|
||||
```bash
|
||||
pip install av numpy
|
||||
```
|
||||
|
||||
2. Run the player, specifying the IPC socket path and the audio file:
|
||||
```bash
|
||||
python main.py --input-ipc-server=/tmp/mpv-socket /path/to/song.flac
|
||||
```
|
||||
@@ -26,6 +26,18 @@ def playback_thread(file_path, state):
|
||||
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:
|
||||
@@ -41,6 +53,7 @@ def playback_thread(file_path, state):
|
||||
with state.lock:
|
||||
state.seek_request = None
|
||||
iterator = container.decode(stream)
|
||||
graph = None # Reset filter graph on seek
|
||||
continue
|
||||
|
||||
if paused:
|
||||
@@ -57,17 +70,44 @@ def playback_thread(file_path, state):
|
||||
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()
|
||||
if graph is None:
|
||||
try:
|
||||
graph = build_graph(frame)
|
||||
except Exception as e:
|
||||
print(f"Filter graph error: {e}", file=sys.stderr)
|
||||
graph = "FAILED"
|
||||
|
||||
# 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)
|
||||
if graph == "FAILED":
|
||||
frames_to_process = [frame]
|
||||
else:
|
||||
try:
|
||||
graph.push(frame)
|
||||
except av.AVError:
|
||||
continue
|
||||
|
||||
fifo.write(arr.tobytes())
|
||||
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 = ""
|
||||
@@ -168,6 +208,9 @@ def main():
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user