Compare commits

..

1 Commits

Author SHA1 Message Date
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 35 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
```
+35 -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():
graph = av.filter.Graph()
src = graph.add_abuffer(template=stream)
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 = build_graph()
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 = build_graph() # Reset filter graph on seek
continue continue
if paused: if paused:
@@ -57,28 +64,30 @@ 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) graph.push(frame)
for r_frame in resampled_frames: while True:
arr = r_frame.to_ndarray() try:
filtered_frame = graph.pull()
# Apply volume except (av.AVError, BlockingIOError):
if vol != 100.0: break
# 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) resampled_frames = resampler.resample(filtered_frame)
multiplier = max(0.0, (mapped_vol / 100.0) ** 3) for r_frame in resampled_frames:
arr = arr.astype(np.float32) * multiplier arr = r_frame.to_ndarray()
arr = np.clip(arr, -32768, 32767).astype(np.int16)
fifo.write(arr.tobytes()) # 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)
fifo.write(arr.tobytes())
def handle_ipc_client(conn, state): def handle_ipc_client(conn, state):
buffer = "" buffer = ""
@@ -122,7 +131,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 +142,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 +167,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()