Compare commits

..

12 Commits

Author SHA1 Message Date
Tanner ae2556dc1d Resample to 44100 Hz 2026-08-06 14:04:39 -06:00
Tanner 8ddbee713a Add license 2026-07-30 14:57:10 -06:00
Tanner 492558b0d5 Update README 2026-07-30 14:56:21 -06:00
Tanner 95cae1a1d5 feat: map volume range to 25-100 before cubic scaling
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
2026-07-30 14:45:37 -06:00
Tanner 8a1d240341 fix: clamp volume input between 0 and 100
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
2026-07-30 14:13:47 -06:00
Tanner f63bf23722 feat: use cubic curve for natural volume scaling
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
2026-07-30 14:11:45 -06:00
Tanner d80d9a0172 Log directly to Docker's log stream 2026-07-30 14:05:38 -06:00
Tanner e94c43ff19 feat: add logging for IPC commands, responses, and errors
Co-authored-by: aider (gemini/gemini-3.1-pro-preview) <aider@aider.chat>
2026-07-30 13:47:59 -06:00
Tanner 1f0c658fff feat: configure logging based on DEBUG environment variable 2026-07-30 13:47:57 -06:00
Tanner 6ad5dd9de3 Adjust README 2026-07-28 17:40:53 -06:00
Tanner df8a33f456 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:40:53 -06:00
Tanner 1fffff87dd docs: add README.md 2026-07-28 17:40:53 -06:00
3 changed files with 117 additions and 35 deletions
+23
View File
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,64 @@
# 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
```
+23 -28
View File
@@ -1,4 +1,10 @@
import os
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 sys
import json
import socket
@@ -24,19 +30,7 @@ def playback_thread(file_path, state):
container = av.open(file_path)
stream = container.streams.audio[0]
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()
resampler = av.AudioResampler(format='s16', layout='stereo', rate=44100)
with open(fifo_path, 'wb') as fifo:
iterator = container.decode(stream)
@@ -53,7 +47,6 @@ def playback_thread(file_path, state):
with state.lock:
state.seek_request = None
iterator = container.decode(stream)
graph = build_graph() # Reset filter graph on seek
continue
if paused:
@@ -64,26 +57,24 @@ def playback_thread(file_path, state):
frame = next(iterator)
except StopIteration:
break
except av.AVError:
except av.AVError as e:
logging.error(f"AVError during decode: {e}")
continue
with state.lock:
state.time_pos = float(frame.pts * stream.time_base)
graph.push(frame)
while True:
try:
filtered_frame = graph.pull()
except (av.AVError, BlockingIOError):
break
resampled_frames = resampler.resample(filtered_frame)
resampled_frames = resampler.resample(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)
# 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)
arr = arr.astype(np.float32) * multiplier
arr = np.clip(arr, -32768, 32767).astype(np.int16)
@@ -131,7 +122,7 @@ def handle_ipc_client(conn, state):
if prop == "pause":
state.paused = bool(val)
elif prop == "volume":
state.volume = float(val)
state.volume = max(0.0, min(100.0, float(val)))
elif prop == "time-pos":
state.seek_request = float(val)
else:
@@ -142,11 +133,14 @@ 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:
except Exception as e:
logging.error(f"IPC Client connection error: {e}")
break
conn.close()
@@ -167,7 +161,8 @@ def ipc_server_thread(socket_path, state):
t.start()
except socket.timeout:
continue
except Exception:
except Exception as e:
logging.error(f"IPC Server error: {e}")
break
server.close()