Compare commits

..

3 Commits

Author SHA1 Message Date
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 58 additions and 117 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.
-64
View File
@@ -1,64 +0,0 @@
# Qotplayer
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
- Decodes and resamples common audio formats to `48000Hz`, `stereo`, `s16` PCM.
- 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).
- Maps 0-100 volume levels to 25-100 and applies log scale (for human hearing).
## Setup
docker-compose.yml:
```
services:
navidrome:
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:
```
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
```
+58 -30
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:
for r_frame in resampled_frames: try:
arr = r_frame.to_ndarray() graph = build_graph(frame)
except Exception as e:
# Apply volume print(f"Filter graph error: {e}", file=sys.stderr)
if vol != 100.0: graph = "FAILED"
# Linearly map 0-100 to 25-100 so low volumes aren't too quiet
mapped_vol = 25.0 + (vol / 100.0) * 75.0
# Use cubic curve for more natural volume adjustment (human hearing is logarithmic) if graph == "FAILED":
multiplier = max(0.0, (mapped_vol / 100.0) ** 3) frames_to_process = [frame]
arr = arr.astype(np.float32) * multiplier else:
arr = np.clip(arr, -32768, 32767).astype(np.int16) 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): 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: