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
```
+51 -23
View File
@@ -1,10 +1,4 @@
import os, logging
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 os
import sys
import json
import socket
@@ -30,7 +24,19 @@ def playback_thread(file_path, state):
container = av.open(file_path)
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:
iterator = container.decode(stream)
@@ -47,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,28 +64,50 @@ def playback_thread(file_path, state):
frame = next(iterator)
except StopIteration:
break
except av.AVError as e:
logging.error(f"AVError during decode: {e}")
except av.AVError:
continue
with state.lock:
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:
arr = r_frame.to_ndarray()
# Apply volume
if vol != 100.0:
# 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)
multiplier = max(0.0, (mapped_vol / 100.0) ** 3)
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 = ""
@@ -122,7 +151,7 @@ def handle_ipc_client(conn, state):
if prop == "pause":
state.paused = bool(val)
elif prop == "volume":
state.volume = max(0.0, min(100.0, float(val)))
state.volume = float(val)
elif prop == "time-pos":
state.seek_request = float(val)
else:
@@ -133,14 +162,11 @@ def handle_ipc_client(conn, state):
else:
resp["error"] = "unknown command"
logging.info(f"IPC Command: {cmd} -> Response: {resp}")
conn.sendall((json.dumps(resp) + '\n').encode('utf-8'))
except Exception as e:
logging.error(f"IPC Error processing line '{line}': {e}")
err_resp = {"error": str(e)}
conn.sendall((json.dumps(err_resp) + '\n').encode('utf-8'))
except Exception as e:
logging.error(f"IPC Client connection error: {e}")
except Exception:
break
conn.close()
@@ -161,8 +187,7 @@ def ipc_server_thread(socket_path, state):
t.start()
except socket.timeout:
continue
except Exception as e:
logging.error(f"IPC Server error: {e}")
except Exception:
break
server.close()
@@ -183,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: