Compare commits
6 Commits
master
..
47a953062a
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a953062a | |||
| 963c72fccc | |||
| 844e57fc93 | |||
| 5fab94c06c | |||
| c100dada93 | |||
| 4b0774b3fa |
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user