Compare commits

...

6 Commits

Author SHA1 Message Date
Tanner 47a953062a Adjust README 2026-07-28 17:34:50 -06:00
Tanner 963c72fccc docs: add README.md with project overview and usage instructions
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
2026-07-28 17:32:46 -06:00
Tanner 844e57fc93 docs: add README.md 2026-07-28 17:32:45 -06:00
Tanner 5fab94c06c fix: handle broken pipe and filter graph failures in playback loop
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
2026-07-28 17:12:04 -06:00
Tanner c100dada93 fix: delay filter graph creation until first frame is decoded
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
2026-07-28 17:10:29 -06:00
Tanner 4b0774b3fa feat: add volume normalization using FFmpeg loudnorm filter
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
2026-07-28 17:08:17 -06:00
2 changed files with 77 additions and 10 deletions
+24
View File
@@ -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
```
+44 -1
View File
@@ -26,6 +26,18 @@ def playback_thread(file_path, state):
stream = container.streams.audio[0] stream = container.streams.audio[0]
resampler = av.AudioResampler(format='s16', layout='stereo', rate=48000) 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: with open(fifo_path, 'wb') as fifo:
iterator = container.decode(stream) iterator = container.decode(stream)
while state.running: while state.running:
@@ -41,6 +53,7 @@ def playback_thread(file_path, state):
with state.lock: with state.lock:
state.seek_request = None state.seek_request = None
iterator = container.decode(stream) iterator = container.decode(stream)
graph = None # Reset filter graph on seek
continue continue
if paused: if paused:
@@ -57,7 +70,30 @@ def playback_thread(file_path, state):
with state.lock: with state.lock:
state.time_pos = float(frame.pts * stream.time_base) state.time_pos = float(frame.pts * stream.time_base)
resampled_frames = resampler.resample(frame) 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: for r_frame in resampled_frames:
arr = r_frame.to_ndarray() arr = r_frame.to_ndarray()
@@ -67,7 +103,11 @@ def playback_thread(file_path, state):
arr = arr.astype(np.float32) * multiplier arr = arr.astype(np.float32) * multiplier
arr = np.clip(arr, -32768, 32767).astype(np.int16) arr = np.clip(arr, -32768, 32767).astype(np.int16)
try:
fifo.write(arr.tobytes()) fifo.write(arr.tobytes())
except BrokenPipeError:
state.running = False
return
def handle_ipc_client(conn, state): def handle_ipc_client(conn, state):
buffer = "" buffer = ""
@@ -168,6 +208,9 @@ def main():
try: try:
playback_thread(args.file, state) playback_thread(args.file, state)
# Keep the main thread alive until Navidrome explicitly sends 'quit'
while state.running:
time.sleep(0.1)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally: