From 9299fee9a0acbe002f95e024f3ca2cb0fbba1562 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 20 Oct 2026 11:30:02 -0700 Subject: [PATCH] Web player served by stemserve Plex browsing, stem mixer with presets, shuffle, repeat all/one, playback speed, queue, waveform seek. stemserve serves web/ at / without the token. stemserve image now builds from the repo root. Co-Authored-By: Claude Opus 5.5 --- README.md | 47 ++- server/Dockerfile | 7 +- server/README.md | 5 +- server/docker-compose.yml | 2 +- server/stemserve.py | 18 + web/app.js | 852 ++++++++++++++++++++++++++++++++++++++ web/index.html | 52 +++ web/style.css | 150 +++++++ 8 files changed, 1113 insertions(+), 20 deletions(-) create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/README.md b/README.md index 56a0530..ab74a08 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,21 @@ The stems folder mirrors the music folder. If a song lives at `Kansas/Leftovertu ## Apps -| | iOS | Android | Desktop player | -|---|---|---|---| -| Per-stem faders, mute, solo, mix presets | yes | yes | yes | -| Waveform seek bar, live stem meters | yes | yes | | -| Saved mix per song | yes | | | -| Karaoke mode with synced lyrics | yes | | | -| Stem playlists (a mix per song) | yes | | | -| Plex playlists | yes | yes | yes | -| Downloads for offline | yes | yes | | -| 10-band and parametric EQ, AutoEQ import | yes | yes | | -| Car | CarPlay | Android Auto | | -| Voice | Siri | Google Assistant | | -| Stem Now | yes | yes | | +| | iOS | Android | Desktop player | Web | +|---|---|---|---|---| +| Per-stem faders, mute, solo, mix presets | yes | yes | yes | yes | +| Waveform seek bar, live stem meters | yes | yes | | waveform | +| Shuffle, repeat all/one | yes | yes | | yes | +| Playback speed (keeps pitch) | yes | | | yes | +| Saved mix per song | yes | | | | +| Karaoke mode with synced lyrics | yes | | | | +| Stem playlists (a mix per song) | yes | | | | +| Plex playlists | yes | yes | yes | yes | +| Downloads for offline | yes | yes | | | +| 10-band and parametric EQ, AutoEQ import | yes | yes | | | +| Car | CarPlay | Android Auto | | | +| Voice | Siri | Google Assistant | | | +| Stem Now | yes | yes | | | Mix presets are Full, Karaoke, A Cappella, No Drums, Rhythm, No Guitar and No Bass. @@ -85,10 +87,10 @@ docker run -d --name stemd --restart unless-stopped --stop-timeout 60 \ The first song downloads the model (about 700 MB). After that the log shows what's left, what it's on, and how fast it's going. -**stemserve** is one Python file using only the standard library. Copy `server/` to `/mnt/user/appdata/stemserve` and run `docker compose up -d --build` there, or by hand: +**stemserve** is one Python file using only the standard library. It also serves the web player from `web/`. Copy the repo's `server/` and `web/` folders to `/mnt/user/appdata/stemserve` and run `docker compose up -d --build` in `server/`, or by hand: ```sh -docker build -t stemserve /mnt/user/appdata/stemserve +docker build -f /mnt/user/appdata/stemserve/server/Dockerfile -t stemserve /mnt/user/appdata/stemserve docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \ -v /mnt/user/Music/Stems:/stems:ro \ -v /mnt/user/appdata/stemserve/cache:/cache \ @@ -158,6 +160,7 @@ CarPlay and Siri need the `carplay-audio` and `siri` entitlements, which a free ### Using it +- **Now Playing:** shuffle and repeat sit either side of the play controls; tap repeat again for repeat one. The speed button (next to the mixer) goes from 0.5× to 2× without changing pitch. - **Mixer:** on Now Playing, under the waveform. Tap **Save** to keep a mix for that song; it comes back every time the song plays. - **Karaoke:** the mic button mutes vocals on every song and shows synced lyrics from [LRCLIB](https://lrclib.net). Tap a line to jump to it. Vocals can be On, Soft or Off, and Soft has a slider for how quiet. - **Playlists:** the Playlists tab switches between Plex and Stem. @@ -211,6 +214,18 @@ adb forward tcp:5277 tcp:5277 ~/Android/Sdk/extras/google/auto/desktop-head-unit -c ~/Android/Sdk/extras/google/auto/config/rotary.ini ``` +## Web + +stemserve also serves a web player at its root, so `http://:8765/` opens it. Sign in with Plex in Settings (or paste a server URL and token), then browse albums, artists, playlists and search like the apps. **Stems only** lists everything in the stems folder and works without Plex. + +- Mixer, presets, shuffle, repeat, speed, queue and the waveform seek bar work like the phone apps. The mix carries over to the next song. +- Songs without stems play straight from Plex. +- Keys: Space play/pause, arrows seek, S shuffle, R repeat, `[` `]` speed, M mixer, Q queue. +- Settings, queue and mix are kept in the browser. Nothing syncs with the apps. +- If stemserve has `TOKEN` set, enter it in Settings. + +It's plain HTML and JavaScript in `web/` with no build step. To run it somewhere else, serve `web/` and proxy `/api/` and `/file/` to stemserve on the same origin; with a different origin the stem faders fall back to element volume, which iOS Safari ignores. + ## Desktop Linux, Python 3.12 and [uv](https://docs.astral.sh/uv/). PyTorch defaults to ROCm (AMD); point the torch index in `pyproject.toml` elsewhere for NVIDIA or CPU. @@ -274,6 +289,7 @@ Stems/Kansas/Leftoverture/01 Carry On Wayward Son/ | `POST /api/prepare?key=K&fmt=opus&br=256` | Starts Opus transcodes in the background | | `POST /api/stem` with `{"paths": [...]}` | Stem Now for these Plex files | | `GET /file/K/.flac[?fmt=opus&br=256]` | The stem, with range support | +| `GET /` | The web player from `web/` (or `WEB_DIR`), no token needed | `K` is the song's folder under the stems root, like `Kansas/Leftoverture/01 Carry On Wayward Son`. Outside Docker: @@ -289,6 +305,7 @@ ios/ iOS and CarPlay app (SwiftUI, xcodegen project) scripts/ build-ipa.sh, make-source.py android/ Android and Android Auto app (Kotlin, Compose, Media3) server/ stemd.py, stemserve.py, Dockerfiles, compose file +web/ web player (served by stemserve) stemify/ desktop separator (PySide6 + QML) and shared separation code worker/ Demucs and RoFormer backends, run in a separate process player/ Stemify Player diff --git a/server/Dockerfile b/server/Dockerfile index 7ecf439..832b9f1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,8 +1,11 @@ +# Build from the repo root so the web player comes along: +# docker build -f server/Dockerfile -t stemserve . FROM python:3.12-alpine RUN apk add --no-cache ffmpeg -COPY stemserve.py /app/stemserve.py +COPY server/stemserve.py /app/server/stemserve.py +COPY web /app/web ENV STEMS_DIR=/stems CACHE_DIR=/cache PORT=8765 PYTHONUNBUFFERED=1 EXPOSE 8765 # Unraid shares belong to nobody:users. USER 99:100 -CMD ["python", "/app/stemserve.py"] +CMD ["python", "/app/server/stemserve.py"] diff --git a/server/README.md b/server/README.md index 48f2b97..d96f54c 100644 --- a/server/README.md +++ b/server/README.md @@ -15,8 +15,9 @@ from the desktop). From an unraid terminal: ```sh mkdir -p /mnt/user/appdata/stemserve/cache /mnt/user/appdata/stemd/state/queue chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd -cp -r /path/to/stemify/server /mnt/user/appdata/stemserve/src # or copy over SMB -docker build -t stemserve /mnt/user/appdata/stemserve/src +mkdir -p /mnt/user/appdata/stemserve/src # needs server/ and web/ from the repo +cp -r /path/to/stemify/{server,web} /mnt/user/appdata/stemserve/src/ # or copy over SMB +docker build -f /mnt/user/appdata/stemserve/src/server/Dockerfile -t stemserve /mnt/user/appdata/stemserve/src docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \ -v /mnt/user/Music/Stems:/stems:ro \ -v /mnt/user/appdata/stemserve/cache:/cache \ diff --git a/server/docker-compose.yml b/server/docker-compose.yml index ab8bed2..cf099b3 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -1,6 +1,6 @@ services: stemserve: - build: . + build: {context: .., dockerfile: server/Dockerfile} image: stemserve:latest container_name: stemserve restart: unless-stopped diff --git a/server/stemserve.py b/server/stemserve.py index f32d73d..3e4e0be 100644 --- a/server/stemserve.py +++ b/server/stemserve.py @@ -16,6 +16,7 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se POST /api/prepare?key=&fmt=opus&br=160 start transcodes in the background POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next /file//[?fmt=opus&br=160] the stem itself, with Range support + / the web player (web/, or WEB_DIR); no token needed is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song". """ @@ -50,6 +51,10 @@ FFMPEG = shutil.which("ffmpeg") OPUS_RATES = (64, 96, 128, 160, 192, 256) PEAK_RATE = 2000 # Hz the envelope is computed at; plenty for a few hundred bars CHUNK = 1 << 20 +# The web player. Served without the token; it asks for one in its settings when TOKEN is set. +WEB_DIR = os.path.realpath(os.environ.get("WEB_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "web"))) +WEB_TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".json": "application/json"} _pool = ThreadPoolExecutor(max_workers=max(2, (os.cpu_count() or 2) // 2)) _locks: dict[str, threading.Lock] = {} @@ -267,6 +272,17 @@ class Handler(BaseHTTPRequestHandler): def _error(self, status, msg): self._send(status, json.dumps({"error": msg}).encode()) + def _web(self, path): + if path == "/": + path = "/index.html" + full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/"))) + if not full.startswith(WEB_DIR + os.sep) or not os.path.isfile(full): + return self._error(404, "not found") + with open(full, "rb") as f: + body = f.read() + ctype = WEB_TYPES.get(os.path.splitext(full)[1], "application/octet-stream") + self._send(200, body, ctype, {"Cache-Control": "no-cache"}) + def do_HEAD(self): self.do_GET() @@ -277,6 +293,8 @@ class Handler(BaseHTTPRequestHandler): url = urllib.parse.urlsplit(self.path) query = urllib.parse.parse_qs(url.query) path = urllib.parse.unquote(url.path) + if not path.startswith(("/api/", "/file/")): + return self._web(path) if not self._authorized(query): return self._error(HTTPStatus.UNAUTHORIZED, "bad token") try: diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..210fcea --- /dev/null +++ b/web/app.js @@ -0,0 +1,852 @@ +// Stemify web player. Browses Plex, plays stems from stemserve, no build step. +// Each stem is its own