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
3 changed files with 70 additions and 105 deletions
-23
View File
@@ -1,23 +0,0 @@
Copyright (c) Everyone, except Author
Everyone is permitted to copy, distribute, modify, merge, sell, publish,
sublicense or whatever they want with this software but at their OWN RISK.
Preamble
The author has absolutely no clue what the code in this project does. It might
just work or not, there is no third option.
GOOD LUCK WITH THAT PUBLIC LICENSE TERMS AND CONDITIONS FOR
COPYING, DISTRIBUTION, AND MODIFICATION
0. You just DO WHATEVER YOU WANT TO as long as you NEVER LEAVE A TRACE TO
TRACK THE AUTHOR of the original product to blame for or hold responsible.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Good luck and Godspeed.
+10 -50
View File
@@ -2,63 +2,23 @@
A lightweight, headless Python audio player designed as a fast drop-in replacement for `mpv`. A lightweight, headless Python audio player designed as a fast drop-in replacement for `mpv`.
This player is specifically built to act as a backend player for **Navidrome** (in Jukebox mode) feeding into **Snapcast**. It avoids the overhead of full `mpv` while maintaining compatibility with its IPC protocol.
This removes about 5 seconds of audio playback lag and UI seeking lag.
## What it does ## What it does
- Decodes and resamples common audio formats to `48000Hz`, `stereo`, `s16` PCM. - 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`). - 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). - Provides a JSON IPC server over a Unix socket for real-time playback control (pause, volume, seek, quit).
- Maps 0-100 volume levels to 25-100 and applies log scale (for human hearing).
## Setup ## 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.
docker-compose.yml: ## How to use it
``` 1. Install the required dependencies (Python 3.11+ recommended):
services: ```bash
navidrome: pip install av numpy
build: .
user: 1000:1000 # should be owner of volumes
ports:
- "4533:4533"
restart: unless-stopped
environment:
ND_JUKEBOX_ENABLED: true
ND_MPVCMDTEMPLATE: '/opt/qotplayer/env/bin/python /opt/qotplayer/main.py %f --input-ipc-server=%s'
ND_LOGLEVEL: info
volumes:
- "./data:/data"
- "/mnt/music-combined:/music:ro"
- "/tmp/snapfifo:/tmp/snapfifo"
``` ```
Dockerfile: 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
FROM deluan/navidrome:latest
RUN apk add --no-cache git curl && \
curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /opt
RUN git clone https://git.tanner.vc/tanner/qotplayer.git
WORKDIR /opt/qotplayer
RUN uv venv --python=3.12 env && \
. env/bin/activate && \
uv pip install -r requirements.txt
RUN chmod 755 /root
```
Build and run with:
```
$ sudo docker compose build --no-cache
$ sudo docker compose up
``` ```
+51 -23
View File
@@ -1,10 +1,4 @@
import os, logging import os
DEBUG = os.environ.get('DEBUG')
logging.basicConfig(
filename='/proc/1/fd/1',
format='[%(asctime)s] %(levelname)s %(module)s/%(funcName)s - %(message)s',
level=logging.DEBUG if DEBUG else logging.INFO)
import sys import sys
import json import json
import socket import socket
@@ -30,7 +24,19 @@ def playback_thread(file_path, state):
container = av.open(file_path) container = av.open(file_path)
stream = container.streams.audio[0] stream = container.streams.audio[0]
resampler = av.AudioResampler(format='s16', layout='stereo', rate=44100) 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)
@@ -47,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,28 +64,50 @@ def playback_thread(file_path, state):
frame = next(iterator) frame = next(iterator)
except StopIteration: except StopIteration:
break break
except av.AVError as e: except av.AVError:
logging.error(f"AVError during decode: {e}")
continue continue
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()
# Apply volume # Apply volume
if vol != 100.0: if vol != 100.0:
# Linearly map 0-100 to 25-100 so low volumes aren't too quiet multiplier = max(0.0, vol / 100.0)
mapped_vol = 25.0 + (vol / 100.0) * 75.0
# Use cubic curve for more natural volume adjustment (human hearing is logarithmic)
multiplier = max(0.0, (mapped_vol / 100.0) ** 3)
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 = ""
@@ -122,7 +151,7 @@ def handle_ipc_client(conn, state):
if prop == "pause": if prop == "pause":
state.paused = bool(val) state.paused = bool(val)
elif prop == "volume": elif prop == "volume":
state.volume = max(0.0, min(100.0, float(val))) state.volume = float(val)
elif prop == "time-pos": elif prop == "time-pos":
state.seek_request = float(val) state.seek_request = float(val)
else: else:
@@ -133,14 +162,11 @@ def handle_ipc_client(conn, state):
else: else:
resp["error"] = "unknown command" resp["error"] = "unknown command"
logging.info(f"IPC Command: {cmd} -> Response: {resp}")
conn.sendall((json.dumps(resp) + '\n').encode('utf-8')) conn.sendall((json.dumps(resp) + '\n').encode('utf-8'))
except Exception as e: except Exception as e:
logging.error(f"IPC Error processing line '{line}': {e}")
err_resp = {"error": str(e)} err_resp = {"error": str(e)}
conn.sendall((json.dumps(err_resp) + '\n').encode('utf-8')) conn.sendall((json.dumps(err_resp) + '\n').encode('utf-8'))
except Exception as e: except Exception:
logging.error(f"IPC Client connection error: {e}")
break break
conn.close() conn.close()
@@ -161,8 +187,7 @@ def ipc_server_thread(socket_path, state):
t.start() t.start()
except socket.timeout: except socket.timeout:
continue continue
except Exception as e: except Exception:
logging.error(f"IPC Server error: {e}")
break break
server.close() server.close()
@@ -183,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: