diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cc05d9a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.venv/ +jobs/ +**/__pycache__/ +*.pyc +.git/ +.gitignore +.idea/ +.vscode/ +.DS_Store +.python-version +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..97650b48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +venv/ +env/ + +# uv / pip +.uv-cache/ +pip-wheel-metadata/ + +# Build / packaging +# Top-level build/ holds the Dockerfile + compose; do not ignore it. +dist/ +*.egg-info/ +*.egg +.eggs/ + +# Test / coverage / type-check caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ + +# Runtime job artifacts (downloaded audio + separated stems) +jobs/ +.run/ + +# Local env / secrets +.env +.env.* +!.env.example +.docs + +# Logs +*.log +logs/ + +# Editors / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Tooling +.python-version +.claude/ +.playwright-mcp/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..a51035bf --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# StemDeck + +Paste a YouTube URL, get the audio split into stems (vocals / drums / bass / guitar / piano / other) and play them back in a DAW-style multitrack mixer. Mute, solo, mix, zoom the waveform, loop a region, and download individual stems or a custom mix. + +Local-only, single-user. One Python process serves both the REST/SSE API and the static frontend. + +> **What is this?** StemDeck is meant to be an open, free, simplified alternative to commercial stem-splitter products like Moises, LALAL.AI, and similar tools. It runs entirely on your own machine, with no account, no quota, no upload, and no subscription. Source-available so you can read, fork, or self-host it. It is not trying to match those products feature-for-feature; it covers the basics well and stops there. + +### Honest comparison + +| | StemDeck | Moises / LALAL.AI / similar | +| --- | --- | --- | +| Price | Free | Freemium or pay-per-use | +| Hosting | Runs locally on your machine | Cloud, upload required | +| Account / login | None | Required | +| Stem model | Demucs `htdemucs_6s` (6 stems) | Proprietary, often higher quality | +| Polish | Functional, hobby-grade UI | Polished apps (web + mobile) | +| Extra features (pitch shift, chord detection, click track, lyrics, BPM tap, mobile apps, cloud library) | No | Yes | +| Privacy | Audio never leaves your machine | Audio uploaded to a third party | +| Source code | Available, modifiable | Closed | + +If you need the polish, the mobile app, or the extra musician-facing tooling, the commercial products are a better fit and worth the money. If you mainly want stems for personal study and prefer to keep things local and free, StemDeck should be enough. + +## Features + +- **6-stem separation** via Demucs `htdemucs_6s`. Auto-detects the best Torch device (CUDA, MPS, CPU); on Apple Silicon you get ~3-5× speedup over CPU for free. +- **DAW-style waveform editor.** Min/max sample rendering across all stems with shared global normalization, zoom in / out / Fit (`+` / `−` / `Cmd-wheel`), loop drag on the ruler, gold playhead overlay, and stem-aligned lanes. +- **Stem subset extraction.** Click stem chips on the import page to pick which stems to keep. Filter-chip semantics: clicking from "all selected" snaps to "only this one"; subsequent clicks add or remove. Selection persists in `localStorage`. +- **"Original" backing track.** When you pick a subset, the studio includes a 7th lane with the *complement* (full song minus the selected stems). Playing it alongside the isolated stems reconstructs the full mix without doubling, which is perfect for A/B reference. +- **Downloadable selected mix.** A single `mix.wav` of just the selected stems, summed via ffmpeg amix. Surfaces as the **Download Mix** button in the footer. +- **Per-stem mixer.** Volume fader, mute, solo, and "monitor" (solo-only) per stem. State is synced between the preview mixer and the stems sidebar. +- **Live VU per stem.** Post-gain RMS via Web Audio analysers on each stem's gain node. Peak hold + slow falloff for the classic DAW meter feel. +- **Song analysis.** BPM (librosa beat tracker on percussive HPSS), key + scale + confidence (Albrecht-Shanahan profile with root-prominence weighting), integrated LUFS (BS.1770 via pyloudnorm), sample peak in dBFS. All surfaced in the now-playing card. +- **Cancellable jobs.** Click cancel mid-pipeline (download, Demucs, ffmpeg amix) and the runner terminates the active subprocess immediately, deletes the partial job dir, and returns the API to ready. + +## Requirements + +- Python 3.10+ +- `ffmpeg` on `PATH` (install instructions per-platform below) +- [uv](https://github.com/astral-sh/uv) (recommended) or `pip` +- ~170 MB free disk for the Demucs `htdemucs_6s` model (downloaded automatically on first run) +- Reasonably modern CPU. An Apple-Silicon `mps` or NVIDIA `cuda` GPU dramatically speeds up separation. + +## Setup + +### macOS / Linux (one-shot) + +```sh +git clone stemdeck && cd stemdeck +./run.sh setup # detects OS, installs ffmpeg + uv, runs `uv sync` +./run.sh start +``` + +`setup` uses Homebrew on macOS and `apt-get` on Debian/Ubuntu. It skips anything that's already installed. For other Linux distros, install `ffmpeg` and [uv](https://github.com/astral-sh/uv) manually, then run `uv sync` followed by `./run.sh start`. + +Open . + +## Running + +### `run.sh` (macOS / Linux) + +The bundled control script manages the dev server as a background process with a PID file and a log: + +```sh +./run.sh setup # one-shot: install ffmpeg + uv (brew/apt), then `uv sync` +./run.sh start # boots uvicorn, writes .run/uvicorn.pid + .run/uvicorn.log +./run.sh stop # graceful shutdown, force-kills after 5 s, sweeps stray demucs children +./run.sh restart # stop + start +./run.sh status # is it running, on which URL +``` + +Environment variables it respects: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `HOST` | `127.0.0.1` | Bind address. Set to `0.0.0.0` to expose on the LAN. | +| `PORT` | `8000` | Listening port. | +| `RELOAD` | `0` | Set to `1` to pass `--reload` for hot reload during development. | +| `FOREGROUND` | `0` | Set to `1` to run uvicorn in the foreground (blocking, no PID file dance). | + +Examples: + +```sh +RELOAD=1 ./run.sh start # dev mode with auto-reload on file change +PORT=9000 ./run.sh start # listen on a different port +HOST=0.0.0.0 ./run.sh start # accessible from other machines on the LAN +FOREGROUND=1 ./run.sh start # blocking; Ctrl-C to stop +tail -f .run/uvicorn.log # follow logs while running in background +``` + +### Manual uvicorn (any platform) + +```sh +uv run uvicorn app.main:app --reload +# or: source .venv/bin/activate && uvicorn app.main:app --reload +``` + +### Docker + +The Dockerfile and compose file live in `build/`: + +```sh +docker compose -f build/docker-compose.yml up --build +``` + +Open . Stems land in `./jobs/` on the host. Demucs model weights are kept in a named volume (`stemdeck-cache`) so they don't re-download on rebuild. + +**Caveats:** + +- **No GPU on macOS Docker.** Docker Desktop on Mac doesn't expose MPS or CUDA, so Demucs runs CPU-only inside the container, which is significantly slower than the native install. Use the native install on Apple Silicon for speed. +- **NVIDIA GPU passthrough (Linux)** isn't enabled in `build/docker-compose.yml` by default. Add `deploy.resources.reservations.devices` with `driver: nvidia` if you want it; you'll also need `nvidia-container-toolkit` on the host. +- **First build is slow.** `pip install .` pulls torch (~2 GB) and friends. Subsequent rebuilds reuse the layer unless `pyproject.toml` changes. +- **First job is slow.** Demucs downloads `htdemucs_6s` weights (~170 MB) on the first run; cached after that. + +## How to use + +1. (Optional) On the import page, click stem chips to pick which stems to extract. By default all 6 are selected. Clicking a chip while everything is highlighted snaps to "only this stem"; subsequent clicks add to or remove from the selection. +2. Paste a YouTube URL and click **Process**. +3. Wait through `Downloading…`, `Analyzing…`, `Separating…`, `Mixing tracks…`. First run also downloads the Demucs model (~170 MB). +4. When it's done, the studio dashboard appears. If you picked a subset, the first lane is **Original** (the song minus the selected stems); the rest are the isolated stems you chose. Otherwise all 6 stems show. +5. Mix: + - **▶ ⏸ ⏹** master transport + - **M** mute, **S** solo (additive: solo two stems and both stay audible) + - **○ Monitor** solo-only this stem (clears other solos) + - **vol fader**: drag for 1:1 movement; double-click resets to 0 dB; `Shift+wheel` for coarse, plain wheel for fine adjustment + - **Reset / Mute / Solo** toolbar buttons at the top reset/toggle all stems at once +6. Wave editor: drag on the ruler to define a loop region, click `Loop` to enable, `+` / `−` / `Fit` to zoom, `Cmd/Ctrl-wheel` to zoom centered on cursor. +7. **Download Mix** in the footer gives you a single WAV of just your selected stems summed together. + +**Keyboard shortcuts:** `Space` play/pause · `[` seek -5s · `]` seek +5s · `L` toggle loop · `I` set loop start at playhead · `O` set loop end at playhead. + +## Layout on disk + +``` +jobs// +└── stems/ + ├── vocals.wav # always: the 6 demucs stems + ├── drums.wav + ├── bass.wav + ├── guitar.wav + ├── piano.wav + ├── other.wav + ├── original.wav # only when a strict subset was selected: + │ # sum of the un-selected stems + └── mix.wav # only when ≥2 stems were selected: + # ffmpeg amix of the selected stems +``` + +The source download is deleted after Demucs finishes (it's 100-300 MB and isn't used again). + +Job state is in-memory only. Restart the server and the job list is wiped, but the files persist on disk. Send `DELETE /api/jobs/` (or just delete `jobs//`) to reclaim space. Old job dirs are also swept automatically when a new job is submitted (TTL 24 h, configurable via `STEMDECK_JOB_TTL_SECONDS`). + +## Configuration + +App environment variables, all optional: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `STEMDECK_DEMUCS_DEVICE` | auto | Force a Torch device: `cuda`, `mps`, or `cpu`. | +| `STEMDECK_DEMUCS_MODEL` | `htdemucs_6s` | Demucs model name. | +| `STEMDECK_JOBS_DIR` | `./jobs` | Where job dirs land. | +| `STEMDECK_MAX_DURATION_SEC` | `1200` | Reject videos longer than this. | +| `STEMDECK_JOB_TTL_SECONDS` | `86400` | How long to keep a job dir on disk. | +| `STEMDECK_MAX_PENDING_JOBS` | `3` | Admission control (currently advisory). | + +## Troubleshooting + +- **`uvicorn not found at .venv/bin/uvicorn`** when running `./run.sh start`: you haven't installed deps yet. Run `uv sync` first. +- **`ffmpeg: command not found`** during a job: install ffmpeg per the platform-specific Setup section above and restart the server (`./run.sh restart`). +- **`WARNING: [youtube] No supported JavaScript runtime could be found`**: yt-dlp needs a JS runtime to reliably pick the best YouTube audio format. Install deno (`brew install deno` on macOS, `curl -fsSL https://deno.land/install.sh | sh` on Linux) and restart. Without it, downloads still work but may pick suboptimal formats. +- **First separation is very slow**: the Demucs model weights download on first run. Subsequent runs reuse the cached weights in `~/.cache/torch/hub/checkpoints`. +- **Demucs runs on CPU and takes minutes**: Demucs picks the best device automatically (CUDA, MPS, CPU). On Apple Silicon you should see MPS acceleration; if not, your `torch` install may be CPU-only. Check the server log on startup for `demucs config: model=htdemucs_6s device=mps`. +- **Browser memory grows on long videos**: the multitrack player decodes each stem into a Web Audio buffer for the overview waveform. A 6-minute song uses a few hundred MB of decoded audio in memory; long lectures will be uncomfortable. Trim the input or close other tabs. +- **Page reloaded mid-job**: the job keeps running on the server, but the UI loses track of it. Wait for it to finish, then re-submit (the stems on disk get overwritten, or, if you want the previous output, find it under `jobs//stems/`). +- **`./run.sh: Permission denied`**: the script lost its executable bit. Run `chmod +x run.sh`. + +## API (for tinkering) + +| Method | Path | Purpose | +| --- | --- | --- | +| POST | `/api/jobs` | Body `{url, stems?}` → `{job_id}`. `stems` is an optional array of stem names; defaults to all 6. | +| GET | `/api/jobs/{id}` | Job state snapshot. | +| GET | `/api/jobs/{id}/events` | Server-Sent Events stream of job state. | +| POST | `/api/jobs/{id}/cancel` | Set the cancel flag and terminate the active subprocess. | +| GET | `/api/jobs/{id}/stems/{name}.wav` | Stream/download a single stem (range requests). `name` ∈ {6 demucs stems, `original`, `mix`}. | +| DELETE | `/api/jobs/{id}` | Remove the job dir from disk (must be done/error/cancelled). | + +## Disclaimer + +StemDeck is an educational project intended for study, research, and experimentation with audio source separation. It bundles two notable third-party tools: + +- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** for downloading audio from URLs you provide. +- **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_6s` model) for source separation. + +You, the user running this software, are solely responsible for how you use it, including: + +- Complying with the terms of service of any site you download from (YouTube's ToS, in particular, restricts automated downloads). +- Respecting the copyright of the audio you process. Separating stems from copyrighted material may be permissible for personal study or fair-use analysis in some jurisdictions, but redistribution of the resulting stems generally is not. +- Following the licenses of the underlying tools and models (yt-dlp, Demucs, FFmpeg, PyTorch, etc.). + +The author(s) of StemDeck provide this code "as is", without warranty of any kind, and accept no responsibility or liability for how it is used. If you're unsure whether a particular use is allowed in your jurisdiction, consult a lawyer before proceeding. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/api/events.py b/app/api/events.py new file mode 100644 index 00000000..57fc0f31 --- /dev/null +++ b/app/api/events.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse + +from app.core.registry import get as registry_get + +router = APIRouter(tags=["events"]) + + +@router.get("/jobs/{job_id}/events") +async def job_events(job_id: str) -> StreamingResponse: + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + + async def stream() -> AsyncIterator[str]: + last = None + keepalive_at = 0 + while True: + snapshot = job.to_state() + serialized = json.dumps(snapshot) + if serialized != last: + yield f"data: {serialized}\n\n" + last = serialized + keepalive_at = 0 + if snapshot["status"] in ("done", "error", "cancelled"): + return + keepalive_at += 1 + if keepalive_at >= 75: # ~15s + yield ": keepalive\n\n" + keepalive_at = 0 + await asyncio.sleep(0.2) + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/app/api/jobs.py b/app/api/jobs.py new file mode 100644 index 00000000..2743c6a0 --- /dev/null +++ b/app/api/jobs.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import asyncio +import shutil +import uuid + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.core.config import JOBS_DIR, STEM_NAMES +from app.core.models import Job +from app.core.registry import get as registry_get +from app.core.registry import get_proc as registry_get_proc +from app.core.registry import register as registry_register +from app.core.registry import remove as registry_remove +from app.pipeline import run_pipeline +from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url + +router = APIRouter(tags=["jobs"]) + + +class JobRequest(BaseModel): + url: str + # Subset of stems to include in the post-processing "selected mix" + # audio file. None = all 6 (no extra mix produced; would equal the + # original). Unknown stem names are dropped silently rather than + # rejected, so a future model with extra stems doesn't break older + # clients pinning the old set. + stems: list[str] | None = None + + +@router.post("") +async def create_job(payload: JobRequest) -> dict[str, str]: + try: + url = validate_youtube_url(payload.url) + except InvalidYouTubeURL as e: + raise HTTPException(status_code=422, detail=str(e)) from e + selected = ( + [s for s in payload.stems if s in STEM_NAMES] + if payload.stems + else list(STEM_NAMES) + ) + if not selected: # everything was unknown -- treat as full set + selected = list(STEM_NAMES) + job = registry_register(Job(id=uuid.uuid4().hex[:12], selected_stems=selected)) + asyncio.create_task(run_pipeline(job, url, JOBS_DIR)) + return {"job_id": job.id} + + +@router.get("/{job_id}") +def get_job(job_id: str) -> dict: + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + return job.to_state() + + +@router.post("/{job_id}/cancel") +def cancel_job(job_id: str) -> dict: + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if job.status in ("done", "error", "cancelled"): + # Already terminal -- return current state without touching anything. + return job.to_state() + job.cancel_requested = True + # If Demucs is the current stage, terminate it immediately so the read + # loop hits EOF and the runner translates that into a `cancelled` state. + proc = registry_get_proc(job_id) + if proc is not None and proc.poll() is None: + proc.terminate() + return job.to_state() + + +@router.delete("/{job_id}") +def delete_job(job_id: str) -> dict[str, str]: + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if job.status not in ("done", "error", "cancelled"): + raise HTTPException(status_code=409, detail="job is still running") + job_dir = JOBS_DIR / job_id + if job_dir.is_dir(): + shutil.rmtree(job_dir, ignore_errors=True) + registry_remove(job_id) + return {"job_id": job_id, "status": "deleted"} diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 00000000..0b5faf9e --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from app.api.events import router as events_router +from app.api.jobs import router as jobs_router +from app.api.stems import router as stems_router + +router = APIRouter() +router.include_router(jobs_router, prefix="/jobs", tags=["jobs"]) +router.include_router(events_router, tags=["events"]) +router.include_router(stems_router, tags=["stems"]) diff --git a/app/api/stems.py b/app/api/stems.py new file mode 100644 index 00000000..d0beb9b8 --- /dev/null +++ b/app/api/stems.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from app.core.config import JOB_ID_RE, JOBS_DIR, STEM_NAMES +from app.core.registry import get as registry_get + +router = APIRouter(tags=["stems"]) + +# Stem files served by this endpoint: the 6 demucs stems + two +# pipeline-produced extras. "original" is the re-encoded source song +# (added when the user picked a strict subset), "mix" is the ffmpeg +# amix of the user's selected stems. +_ALLOWED_NAMES = frozenset(STEM_NAMES) | {"original", "mix"} + + +@router.get("/jobs/{job_id}/stems/{name}.wav") +def get_stem(job_id: str, name: str) -> FileResponse: + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + if name not in _ALLOWED_NAMES: + raise HTTPException(status_code=404, detail="unknown stem") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + # Resolve and confirm the path stays under JOBS_DIR -- belt and suspenders + # on top of the regex above. Mirrors the check in app/pipeline/analyze.py. + path = (JOBS_DIR / job_id / "stems" / f"{name}.wav").resolve() + if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()): + raise HTTPException(status_code=404, detail="stem not found") + return FileResponse(path, media_type="audio/wav", filename=f"{name}.wav") diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 00000000..27dbafd5 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,51 @@ +import os +import re +from pathlib import Path + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name, "").strip() + try: + return int(raw) if raw else default + except ValueError: + return default + + +def _env_path(name: str, default: Path) -> Path: + raw = os.environ.get(name, "").strip() + return Path(raw).expanduser().resolve() if raw else default + + +def _detect_device() -> str: + """Pick best available Torch device for Demucs. Override via + STEMDECK_DEMUCS_DEVICE env var ('cuda' | 'mps' | 'cpu'). Apple Silicon + silently falls back to CPU otherwise -- demucs's CLI default is + "cuda if available else cpu" and macOS has no CUDA, leaving the + integrated GPU idle and processing 3-5x slower than necessary.""" + forced = os.environ.get("STEMDECK_DEMUCS_DEVICE", "").strip().lower() + if forced in ("cuda", "mps", "cpu"): + return forced + try: + import torch + + if torch.cuda.is_available(): + return "cuda" + if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available(): + return "mps" + except ImportError: + pass + return "cpu" + + +ROOT = Path(__file__).resolve().parent.parent.parent +STATIC_DIR = ROOT / "static" +STEM_NAMES: tuple[str, ...] = ("vocals", "drums", "bass", "guitar", "piano", "other") +JOB_ID_RE = re.compile(r"^[a-f0-9]{12}$") + +# Runtime knobs -- env-backed so Docker / local can tune without a code edit. +JOBS_DIR = _env_path("STEMDECK_JOBS_DIR", ROOT / "jobs") +DEMUCS_MODEL = os.environ.get("STEMDECK_DEMUCS_MODEL", "htdemucs_6s").strip() or "htdemucs_6s" +DEMUCS_DEVICE = _detect_device() +MAX_DURATION_SEC = _env_int("STEMDECK_MAX_DURATION_SEC", 1200) # 20 min default +JOB_TTL_SECONDS = _env_int("STEMDECK_JOB_TTL_SECONDS", 24 * 3600) # 24 h default +MAX_PENDING_JOBS = _env_int("STEMDECK_MAX_PENDING_JOBS", 3) diff --git a/app/core/models.py b/app/core/models.py new file mode 100644 index 00000000..c3ca0d1f --- /dev/null +++ b/app/core/models.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Any + + +class JobCancelled(Exception): + """Raised inside a pipeline stage when the job's cancel flag is set.""" + + +@dataclass +class Job: + id: str + status: str = ( + "queued" # queued | downloading | analyzing | separating | done | error | cancelled + ) + progress: float = 0.0 + stage_message: str = "Queued" + title: str | None = None + duration_sec: float | None = None + thumbnail: str | None = None + bpm: int | None = None + key: str | None = None + scale: str | None = None # "Major" / "Natural Minor" + key_confidence: int | None = None # 0-100 percent + lufs: float | None = None # ITU-R BS.1770 integrated loudness (dB) + peak_db: float | None = None # sample peak in dBFS (close to true peak) + stems: list[dict[str, str]] = field(default_factory=list) + # Subset of stems the user chose at submit. The pipeline produces all + # 6 regardless (Demucs htdemucs_6s is fixed), but after collect we + # mix down only the selected ones into mix.wav so the user can + # download a single track containing just their chosen stems. + selected_stems: list[str] = field(default_factory=list) + mix_url: str | None = None # populated when a strict subset was selected + error: str | None = None + # Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages. + # Not surfaced via to_state() -- it's internal control state. + cancel_requested: bool = False + # Wall-clock timestamps for metadata-based sweep -- more predictable + # than directory mtime, which can be touched by unrelated FS events. + created_at: float = field(default_factory=time.time) + + def to_state(self) -> dict[str, Any]: + return { + "job_id": self.id, + "status": self.status, + "progress": self.progress, + "stage": self.stage_message, + "title": self.title, + "duration": self.duration_sec, + "thumbnail": self.thumbnail, + "bpm": self.bpm, + "key": self.key, + "scale": self.scale, + "key_confidence": self.key_confidence, + "lufs": self.lufs, + "peak_db": self.peak_db, + "stems": self.stems, + "selected_stems": self.selected_stems, + "mix_url": self.mix_url, + "error": self.error, + } diff --git a/app/core/registry.py b/app/core/registry.py new file mode 100644 index 00000000..8e47a2bc --- /dev/null +++ b/app/core/registry.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import subprocess + +from app.core.models import Job + +_jobs: dict[str, Job] = {} +# Active subprocesses keyed by job_id (currently only Demucs). Lets +# POST /cancel terminate the running process from the API thread instead +# of waiting for the pipeline thread to notice the cancel flag. +_procs: dict[str, subprocess.Popen] = {} + + +def register(job: Job) -> Job: + _jobs[job.id] = job + return job + + +def get(job_id: str) -> Job | None: + return _jobs.get(job_id) + + +def remove(job_id: str) -> None: + _jobs.pop(job_id, None) + _procs.pop(job_id, None) + + +def all_jobs() -> dict[str, Job]: + """Return a snapshot of the registry for sweep / cleanup.""" + return dict(_jobs) + + +def set_proc(job_id: str, proc: subprocess.Popen | None) -> None: + if proc is None: + _procs.pop(job_id, None) + else: + _procs[job_id] = proc + + +def get_proc(job_id: str) -> subprocess.Popen | None: + return _procs.get(job_id) diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..db5b138b --- /dev/null +++ b/app/main.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import logging + +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles + +from app.api.router import router +from app.core.config import DEMUCS_DEVICE, DEMUCS_MODEL, JOBS_DIR, STATIC_DIR + +# Show our INFO-level logs through uvicorn's root handler. Without this, +# Python's default root level (WARNING) silently drops every +# logger.info(...) call across the app, including the analyze +# diagnostics ("chroma:", "key candidates:"). +logging.getLogger("stemdeck").setLevel(logging.INFO) +logging.getLogger("stemdeck").info("demucs config: model=%s device=%s", DEMUCS_MODEL, DEMUCS_DEVICE) + +# Pre-import librosa so the first job submission doesn't pay the 1-2 s +# cost of numpy/scipy/numba lazy initialization. Adds ~1 s to server +# boot in exchange for snappier first-job UX. Best-effort: if librosa +# isn't installed, analyze() degrades gracefully on its own. +try: + import librosa # noqa: F401 -- intentional warm-up import +except ImportError: + pass + +app = FastAPI(title="StemDeck") + + +# Force browsers to revalidate static assets on every request. Without +# this the JS/CSS modules can stick in disk cache across server +# restarts -- updated HTML loads against stale modules and the form +# silently breaks. `must-revalidate` keeps 304s working (cheap) while +# guaranteeing the latest mtime is honored. +@app.middleware("http") +async def no_cache_static(request: Request, call_next): + response = await call_next(request) + if not request.url.path.startswith("/api"): + response.headers["Cache-Control"] = "no-cache, must-revalidate" + return response + + +app.include_router(router, prefix="/api") +app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + +# Ensure the jobs directory exists at startup (module-level side effect +# moved from the old monolithic main.py; this is the canonical entrypoint). +JOBS_DIR.mkdir(exist_ok=True) diff --git a/app/pipeline/__init__.py b/app/pipeline/__init__.py new file mode 100644 index 00000000..3e927f83 --- /dev/null +++ b/app/pipeline/__init__.py @@ -0,0 +1,4 @@ +from app.core.config import STEM_NAMES +from app.pipeline.runner import run_pipeline + +__all__ = ["run_pipeline", "STEM_NAMES"] diff --git a/app/pipeline/analyze.py b/app/pipeline/analyze.py new file mode 100644 index 00000000..93eed7eb --- /dev/null +++ b/app/pipeline/analyze.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path + +from app.core.config import JOBS_DIR +from app.core.models import Job +from app.pipeline.download import _set + +logger = logging.getLogger("stemdeck.analyze") + +# Albrecht-Shanahan key profiles, derived from a corpus of popular music +# (Albrecht & Shanahan, 2013). Critically, the minor profile here weights +# b7 high (3.48) and M7 low (0.81) — the opposite of Temperley/Kostka-Payne, +# which were derived from Bach chorales and bias toward harmonic minor's +# leading tone. Pop/rock uses natural minor: the b7 is the diatonic +# seventh and rings out constantly (e.g. open D in "Come As You Are", +# which is in E minor and uses D as the b7). Values rescaled so that the +# tonic weight is ≈5 to match the prior code's magnitude. +_MAJOR_PROFILE = ( + 5.47, + 0.14, + 2.55, + 0.14, + 3.15, + 2.16, + 0.37, + 4.92, + 0.21, + 1.84, + 0.18, + 1.86, +) +_MINOR_PROFILE = ( + 5.06, + 0.14, + 2.42, + 2.42, + 0.35, + 1.96, + 0.35, + 4.16, + 2.53, + 0.28, + 2.67, + 0.62, +) +_PITCHES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") + +# When the best-major and best-minor scores are this close, we prefer +# minor. Pop/rock has a strong minor-mode prior; the algorithm often +# walks toward the relative major because of an ostinato bass note +# (e.g. "Come As You Are" hammers the open D string in an E minor song), +# and minor is the better default when the call is genuinely ambiguous. +_MINOR_TIE_BREAK_FRAC = 0.05 + + +def _correlate(profile: tuple[float, ...], chroma: list[float], shift: int) -> float: + n = len(profile) + rotated = [chroma[(i + shift) % n] for i in range(n)] + mean_p = sum(profile) / n + mean_c = sum(rotated) / n + num = sum((profile[i] - mean_p) * (rotated[i] - mean_c) for i in range(n)) + denom_p = sum((profile[i] - mean_p) ** 2 for i in range(n)) ** 0.5 + denom_c = sum((rotated[i] - mean_c) ** 2 for i in range(n)) ** 0.5 + if denom_p == 0 or denom_c == 0: + return 0.0 + return num / (denom_p * denom_c) + + +def _detect_key(chroma_mean: list[float]) -> tuple[str, str, int]: + """Find the best-matching key by combining profile correlation with + root prominence. The Pearson correlation alone is fooled by relative + keys whose diatonic notes happen to overlap with the song's loud + pitches but whose own tonic is weak (e.g. picking A minor for an + E-minor song because E is its 5th and D is its 4th). Weighting by + the candidate root's chroma value forces the algorithm to also + confirm 'is this proposed tonic actually loud in the audio?'. + Logs the chroma vector and top-5 candidates for diagnostics. + + Returns (label, scale_name, confidence_pct). + - label: e.g. "G# maj" + - scale_name: "Major" or "Natural Minor" + - confidence_pct: 0-100, derived from the gap between the winning + candidate and the runner-up, normalized so a clear + win ranks high and a near-tie ranks low.""" + raw: list[tuple[float, float, str, int]] = [] # (weighted, pearson, label, root_idx) + for shift in range(12): + root_strength = chroma_mean[shift] + pearson_maj = _correlate(_MAJOR_PROFILE, chroma_mean, shift) + pearson_min = _correlate(_MINOR_PROFILE, chroma_mean, shift) + # Multiplicative root weighting. Pearson can be negative; when + # it is, a low-chroma root makes things less negative (closer to + # zero), which is actually the desired ordering. + raw.append((pearson_maj * root_strength, pearson_maj, f"{_PITCHES[shift]} maj", shift)) + raw.append((pearson_min * root_strength, pearson_min, f"{_PITCHES[shift]} min", shift)) + raw.sort(key=lambda x: x[0], reverse=True) + + # Diagnostic log: chroma profile + top 5 candidates with both raw + # and weighted scores. Lets us see what the algorithm is "hearing". + chroma_str = ", ".join(f"{_PITCHES[i]}={chroma_mean[i]:.3f}" for i in range(12)) + top5_str = ", ".join( + f"{label}={weighted:+.3f}(p{pearson:+.2f}*r{chroma_mean[idx]:.2f})" + for weighted, pearson, label, idx in raw[:5] + ) + logger.warning("chroma: %s", chroma_str) + logger.warning("key candidates (top 5): %s", top5_str) + + # Pick best major and best minor for the tie-break, both by the + # weighted score. + best_maj = next(c for c in raw if c[2].endswith("maj")) + best_min = next(c for c in raw if c[2].endswith("min")) + + gap = abs(best_maj[0] - best_min[0]) + threshold = max(abs(best_maj[0]), abs(best_min[0])) * _MINOR_TIE_BREAK_FRAC + # Near-tie -> prefer minor (pop/rock prior); clear winner -> use it. + winner = (best_maj if best_maj[0] > best_min[0] else best_min) if gap > threshold else best_min + + # Confidence: gap between the winner and the runner-up that *isn't* + # the relative major/minor of the winner (those will always be near- + # ties with the algorithm's profile-correlation approach, so they + # tell us nothing about real ambiguity). Normalize so a healthy 0.15 + # gap = 100% confident; tiny gap = 0%. + runner_up = next(c for c in raw if c[2] != winner[2]) + confidence_score = winner[0] - runner_up[0] + confidence_pct = max(0, min(100, round(confidence_score / 0.15 * 100))) + + label = winner[2] + scale_name = "Major" if label.endswith("maj") else "Natural Minor" + return label, scale_name, confidence_pct + + +def _measure_loudness(y: object, sr: int) -> tuple[float | None, float | None]: + """Compute integrated loudness (LUFS, BS.1770) and sample peak (dBFS) + of the loaded mono signal. Returns (lufs, peak_db); either may be + None on failure or silence. We use sample peak rather than oversampled + true peak -- the difference is typically <1 dB and not worth the 4x + resample cost for a display field.""" + import numpy as np + + if y is None or getattr(y, "size", 0) == 0: + return None, None + + peak_lin = float(np.abs(y).max()) + peak_db = 20.0 * float(np.log10(peak_lin)) if peak_lin > 1e-9 else None + + lufs: float | None = None + try: + import pyloudnorm as pyln + + meter = pyln.Meter(sr) # BS.1770-4 with default 400ms blocks + lufs_raw = float(meter.integrated_loudness(y)) + # pyloudnorm returns -inf for silence; surface as None instead so + # the frontend can hide the field rather than render "-inf LUFS". + if np.isfinite(lufs_raw): + lufs = lufs_raw + except (ImportError, ValueError) as e: + # ValueError fires if the clip is shorter than the gating window. + logger.warning("LUFS measurement failed: %s", e) + return lufs, peak_db + + +def _load_audio_ffmpeg( + source: Path, sr: int = 22050, duration: float = 180.0 +) -> tuple[object, int] | None: + """Decode `source` to a mono float32 numpy array at `sr` via ffmpeg. + Bypasses librosa's deprecated audioread fallback (which fires a + FutureWarning on .webm/.m4a/.opus inputs because soundfile can't + read those directly). Returns (samples, sr) or None on failure.""" + import numpy as np + + # Defence in depth: even though `source` is constructed by the server + # (never user-typed), confirm it's a real file inside JOBS_DIR before + # handing it to a subprocess. Belt-and-suspenders against a future + # caller change that would let a path slip in from elsewhere. + resolved = source.resolve() + jobs_resolved = JOBS_DIR.resolve() + if not resolved.is_file(): + logger.warning("analyze source is not a file: %s", source) + return None + if not resolved.is_relative_to(jobs_resolved): + logger.warning( + "analyze source escapes JOBS_DIR (%s not under %s)", + resolved, + jobs_resolved, + ) + return None + + cmd = [ + "ffmpeg", + "-nostdin", + "-loglevel", + "error", + "-i", + str(resolved), + "-ac", + "1", # mono + "-ar", + str(sr), # resample + "-f", + "f32le", # raw 32-bit float little-endian + "-t", + str(duration), # cap input duration + "-", # write to stdout + ] + try: + proc = subprocess.run(cmd, capture_output=True, check=True, timeout=120) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.warning("ffmpeg decode failed for %s: %s", source, e) + return None + y = np.frombuffer(proc.stdout, dtype=np.float32) + if y.size == 0: + return None + return y, sr + + +def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: + """Best-effort BPM and key detection. On failure, returns (None, None) + and leaves job fields untouched -- the chips stay as placeholders.""" + logger.warning("analyze: entering for job %s, source=%s", job.id, source) + _set(job, status="analyzing", progress=0.0, stage="Analyzing audio...") + try: + import librosa + except ImportError: + logger.warning("librosa not installed -- skipping BPM/key analysis") + return None, None + + try: + # Analyse the first 180 s. Decode via ffmpeg directly into numpy + # to avoid librosa's deprecated audioread fallback for + # .webm/.m4a/.opus inputs. + loaded = _load_audio_ffmpeg(source, sr=22050, duration=180.0) + if loaded is None: + return None, None + y, sr = loaded + + # Harmonic / percussive separation. Beat tracking sees a cleaner + # onset envelope on the percussive component; chroma sees a + # cleaner pitch profile on the harmonic component (no cymbal + # smear, no kick fundamentals leaking in). + y_harmonic, y_percussive = librosa.effects.hpss(y) + + tempo_arr, _ = librosa.beat.beat_track(y=y_percussive, sr=sr) + try: + tempo = float(tempo_arr[0]) # type: ignore[index] + except (TypeError, IndexError): + tempo = float(tempo_arr) + bpm = int(round(tempo)) if tempo > 0 else None + + # chroma_cqt is constant-Q based — better pitch resolution than + # chroma_stft, especially in the bass register where the open + # strings of a guitar live. + chroma = librosa.feature.chroma_cqt(y=y_harmonic, sr=sr) + chroma_mean = chroma.mean(axis=1).tolist() + if any(chroma_mean): + key, scale, key_confidence = _detect_key(chroma_mean) + else: + key, scale, key_confidence = None, None, None + + # LUFS / peak. Computed on the same 22 kHz mono buffer; this + # loses a few dB of accuracy vs full-sample-rate stereo, but + # it's good enough for a UI display and adds ~50 ms to analyze. + lufs, peak_db = _measure_loudness(y, sr) + + _set( + job, + bpm=bpm, + key=key, + scale=scale, + key_confidence=key_confidence, + lufs=lufs, + peak_db=peak_db, + progress=1.0, + stage="Analysis complete", + ) + return bpm, key + except Exception as e: + logger.exception("analyze failed for job %s", job.id) + _set(job, stage=f"Analysis skipped ({e})") + return None, None diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py new file mode 100644 index 00000000..8efee16d --- /dev/null +++ b/app/pipeline/collect.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import logging +import shutil +import subprocess +import time +from pathlib import Path + +from app.core.config import DEMUCS_MODEL, JOB_TTL_SECONDS, STEM_NAMES +from app.core.models import Job +from app.core.registry import all_jobs as registry_all +from app.core.registry import remove as registry_remove +from app.core.registry import set_proc + +logger = logging.getLogger("stemdeck.collect") + + +def _run_ffmpeg(job: Job, cmd: list[str]) -> bool: + """Run an ffmpeg command, registering the subprocess with the job + registry so POST /api/jobs/{id}/cancel can terminate it. Returns + True on success, False on failure or external termination. + + Without registering the proc, an in-flight ffmpeg amix would block + cancellation for up to its 300s timeout -- the cancel flag is set + but the runner can't see it until subprocess.run returns. With + set_proc, the cancel API can call proc.terminate() directly and + communicate() returns within ~1s with a non-zero returncode.""" + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + set_proc(job.id, proc) + try: + try: + _, stderr = proc.communicate(timeout=300) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + logger.warning("ffmpeg timed out for job %s", job.id) + return False + if proc.returncode != 0: + tail = (stderr or b"").decode(errors="replace").splitlines()[-3:] + logger.warning( + "ffmpeg exit %s for job %s: %s", + proc.returncode, job.id, " | ".join(tail) or "(no stderr)", + ) + return False + return True + finally: + set_proc(job.id, None) + +_TERMINAL = frozenset(("done", "error", "cancelled")) + + +def collect(job: Job, stems_root: Path, job_dir: Path) -> list[str]: + """Move Demucs-emitted stems into the job's stems/ dir and clean up + the demucs intermediate dir. Does NOT delete the source download -- + cleanup_source() is called by the runner after any post-processing + that needs to re-encode the source (e.g. building original.wav).""" + target_dir = job_dir / "stems" + target_dir.mkdir(exist_ok=True) + found: list[str] = [] + for name in STEM_NAMES: + src = stems_root / f"{name}.wav" + if src.exists(): + shutil.move(str(src), target_dir / f"{name}.wav") + found.append(name) + shutil.rmtree(job_dir / DEMUCS_MODEL, ignore_errors=True) + if not found: + raise RuntimeError("no stems produced by demucs") + return found + + +def cleanup_source(job_dir: Path) -> None: + """Delete the source audio file. Called after collect AND after any + post-processing that re-encodes the source (make_original_track). + The source is 100-300 MB, so getting rid of it is the bulk of disk + reclaim per job; only the stems remain.""" + for f in job_dir.glob("source.*"): + f.unlink(missing_ok=True) + + +def make_original_track( + job: Job, job_dir: Path, stems_dir: Path +) -> Path | None: + """Build the "Original" backing track at stems/original.wav as the + sum of the stems the user did NOT select. This way the studio can + play (original + each selected stem) and reconstruct the full song + without doubling the selected stems -- which is what would happen + if "original" were the raw source download (drum hits in original + + isolated drums.wav = drums at 2x amplitude). + + Skipped when the user kept all 6 stems (no complement to mix) or + when none of the unselected stem WAVs are on disk.""" + unselected = [s for s in STEM_NAMES if s not in job.selected_stems] + inputs = [stems_dir / f"{name}.wav" for name in unselected] + inputs = [p for p in inputs if p.exists()] + if not inputs: + return None + out = stems_dir / "original.wav" + cmd: list[str] = [ + "ffmpeg", + "-y", + "-nostdin", + "-loglevel", + "error", + ] + for p in inputs: + cmd += ["-i", str(p)] + if len(inputs) == 1: + # Single complement stem -- copy as-is so we still produce a + # canonical mix.wav-shaped output without invoking amix on a + # 1-input graph (which is a no-op anyway). + cmd += ["-c:a", "pcm_s16le", str(out)] + else: + filter_inputs = "".join(f"[{i}:a]" for i in range(len(inputs))) + cmd += [ + "-filter_complex", + f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0", + "-c:a", + "pcm_s16le", + str(out), + ] + return out if _run_ffmpeg(job, cmd) else None + + +def make_selected_mix( + job: Job, stems_dir: Path, found: list[str] +) -> Path | None: + """If the user picked a strict subset of stems at submit time, + sum those stems with ffmpeg amix into mix.wav. Returns the output + path on success, or None when there's nothing to mix. + + Returns the existing single stem path (no ffmpeg) if exactly one + stem was selected -- copying it to mix.wav would be 30 MB of + duplicate data. The caller uses the returned path's name for the + download URL, so a single-stem selection points the Download Mix + button directly at the existing stem file. + + amix normalize=0 keeps stem amplitudes as-is. Demucs separations + sum back to (close to) the original signal, so a 2-stem subset + fits comfortably below 0 dBFS without normalization headroom.""" + selected = [s for s in job.selected_stems if s in found] + if not selected or set(selected) == set(found): + return None # no subset -> "mix" would just be the original + if len(selected) == 1: + return stems_dir / f"{selected[0]}.wav" + inputs = [stems_dir / f"{name}.wav" for name in selected] + out = stems_dir / "mix.wav" + cmd: list[str] = [ + "ffmpeg", + "-y", + "-nostdin", + "-loglevel", + "error", + ] + for p in inputs: + cmd += ["-i", str(p)] + filter_inputs = "".join(f"[{i}:a]" for i in range(len(inputs))) + cmd += [ + "-filter_complex", + f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0", + "-c:a", + "pcm_s16le", + str(out), + ] + return out if _run_ffmpeg(job, cmd) else None + + +def sweep_old_jobs(jobs_dir: Path) -> None: + """Delete job directories older than JOB_TTL_SECONDS and remove them from + the in-memory registry. Called once per new job submission so stale data + doesn't accumulate on disk. + + Prefers Job.created_at over directory mtime (which can be touched by + unrelated filesystem events), and never deletes the directory of an + active (non-terminal) registered job even if its timestamp looks old. + Falls back to mtime for orphan directories left over from a previous + server run, since the registry is in-memory only.""" + cutoff = time.time() - JOB_TTL_SECONDS + if not jobs_dir.is_dir(): + return + jobs = registry_all() + for d in jobs_dir.iterdir(): + if not d.is_dir(): + continue + job = jobs.get(d.name) + if job is not None: + if job.status not in _TERMINAL: + continue # never delete an active job's working dir + if job.created_at >= cutoff: + continue + elif d.stat().st_mtime >= cutoff: + continue + shutil.rmtree(d, ignore_errors=True) + registry_remove(d.name) diff --git a/app/pipeline/download.py b/app/pipeline/download.py new file mode 100644 index 00000000..60f62dac --- /dev/null +++ b/app/pipeline/download.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import re +import urllib.parse +from pathlib import Path + +from yt_dlp import YoutubeDL + +from app.core.config import MAX_DURATION_SEC +from app.core.models import Job, JobCancelled + +_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$") +_ALLOWED_HOSTS = frozenset( + ( + "youtube.com", + "www.youtube.com", + "m.youtube.com", + "music.youtube.com", + "youtu.be", + ) +) + + +class InvalidYouTubeURL(ValueError): + """Raised at the API boundary for URLs we won't hand to yt-dlp.""" + + +def validate_youtube_url(url: str) -> str: + """Reject anything that isn't an http(s) URL on a known YouTube host, then + return the normalized single-video form. Keeps StemDeck from acting as a + generic URL fetcher and gives callers a clean 422 instead of a yt-dlp + extractor stack trace.""" + if not isinstance(url, str) or not url.strip(): + raise InvalidYouTubeURL("URL is required") + url = url.strip() + try: + parsed = urllib.parse.urlparse(url) + except Exception as e: + raise InvalidYouTubeURL(f"could not parse URL: {e}") from e + if parsed.scheme not in ("http", "https"): + raise InvalidYouTubeURL("URL must use http or https") + host = (parsed.hostname or "").lower() + if host not in _ALLOWED_HOSTS: + raise InvalidYouTubeURL(f"unsupported host: {host or '(empty)'}") + + normalized = normalize_youtube_url(url) + # normalize_youtube_url returns the original on playlist-only URLs with + # no derivable seed video. We always expect the canonical watch?v=... form. + if not normalized.startswith("https://www.youtube.com/watch?v="): + raise InvalidYouTubeURL("could not extract a video ID from URL") + return normalized + + +def normalize_youtube_url(url: str) -> str: + """Coerce a YouTube URL to a single-video form so yt-dlp doesn't end up in + the playlist extractor. Pass non-YouTube URLs through unchanged. + + Cases handled: + * `watch?v=X&list=...` -> `watch?v=X` (drop the playlist context) + * `?list=RD&start_radio=1` -> `watch?v=` (Radio + playlists embed the seed in the list ID; YouTube refuses to view the + playlist directly with "This playlist type is unviewable.") + * `youtu.be/` -> `watch?v=` + Everything else (PL/OL/algorithmic playlists with no derivable seed) is + left alone -- yt-dlp will surface its own error. + """ + try: + parsed = urllib.parse.urlparse(url) + except Exception: + return url + host = (parsed.hostname or "").lower() + for prefix in ("www.", "m.", "music."): + if host.startswith(prefix): + host = host[len(prefix) :] + break + if host not in ("youtube.com", "youtu.be"): + return url + + qs = urllib.parse.parse_qs(parsed.query) + if (vid := (qs.get("v") or [None])[0]) and _VIDEO_ID_RE.match(vid): + return f"https://www.youtube.com/watch?v={vid}" + + if ( + (lst := (qs.get("list") or [None])[0]) + and lst.startswith("RD") + and _VIDEO_ID_RE.match(lst[2:13]) + ): + return f"https://www.youtube.com/watch?v={lst[2:13]}" + + if host == "youtu.be": + vid = parsed.path.lstrip("/") + if _VIDEO_ID_RE.match(vid): + return f"https://www.youtube.com/watch?v={vid}" + + return url + + +def _set(job: Job, **fields: object) -> None: + """Mutate Job fields. Polling SSE picks the change up automatically.""" + for k, v in fields.items(): + if k == "stage": + job.stage_message = v # type: ignore[assignment] + else: + setattr(job, k, v) + + +def download(job: Job, url: str, job_dir: Path) -> Path: + url = normalize_youtube_url(url) + _set(job, status="downloading", progress=0.0, stage="Processing...") + + # Fetch metadata first (no download) so we can reject videos that are + # too long before wasting bandwidth and disk. + with YoutubeDL({"quiet": True, "noplaylist": True}) as ydl: + meta = ydl.extract_info(url, download=False) or {} + duration = meta.get("duration") or 0 + if duration > MAX_DURATION_SEC: + mins = MAX_DURATION_SEC // 60 + raise RuntimeError(f"Video is {int(duration // 60)} min -- limit is {mins} min") + + def hook(d: dict) -> None: + # yt-dlp calls this on each chunk; raising here aborts the download. + # The runner unwraps yt-dlp's DownloadError and routes to JobCancelled. + if job.cancel_requested: + raise JobCancelled() + if d.get("status") == "downloading": + total = d.get("total_bytes") or d.get("total_bytes_estimate") + if total: + p = float(d.get("downloaded_bytes", 0)) / float(total) + _set(job, progress=p, stage=f"Downloading {int(p * 100)}%") + elif d.get("status") == "finished": + _set(job, progress=1.0, stage="Download complete") + + # No postprocessors -- Demucs reads the raw audio container (webm/m4a/opus/...) + # directly via torchaudio + ffmpeg. Skipping the WAV transcode saves the slowest + # part of the download pipeline and a lot of disk. + ydl_opts = { + "format": "bestaudio/best", + "outtmpl": str(job_dir / "source.%(ext)s"), + "quiet": True, + "noprogress": True, + "noplaylist": True, + "progress_hooks": [hook], + } + with YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=True) or {} + + _set( + job, + title=info.get("title") or meta.get("title"), + duration_sec=info.get("duration") or duration, + thumbnail=info.get("thumbnail") or meta.get("thumbnail"), + ) + + candidates = sorted(job_dir.glob("source.*")) + if not candidates: + raise RuntimeError("yt-dlp finished but no source file was produced") + return candidates[0] diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py new file mode 100644 index 00000000..2ea1ac4b --- /dev/null +++ b/app/pipeline/runner.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import logging +import shutil +from pathlib import Path + +from app.core.models import Job, JobCancelled +from app.pipeline.analyze import analyze +from app.pipeline.collect import ( + cleanup_source, + collect, + make_original_track, + make_selected_mix, + sweep_old_jobs, +) +from app.pipeline.download import _set, download +from app.pipeline.separate import separate + +logger = logging.getLogger("stemdeck.pipeline") + +# Only one heavy job runs at a time -- Demucs is GPU/CPU-hungry. +_pipeline_lock = asyncio.Semaphore(1) + + +def _check_cancel(job: Job) -> None: + if job.cancel_requested: + raise JobCancelled() + + +def _run_blocking(job: Job, url: str, job_dir: Path) -> None: + _check_cancel(job) + source = download(job, url, job_dir) + _check_cancel(job) + analyze(job, source) + _check_cancel(job) + stems_root = separate(job, source, job_dir) + found = collect(job, stems_root, job_dir) + stems_dir = job_dir / "stems" + # Source download (100-300 MB) is no longer used by anything below + # -- both the original-complement and the selected-mix are built + # from the demucs-emitted stems. Reclaim disk before the ffmpeg + # amix steps in case they need scratch space. + cleanup_source(job_dir) + job.stems = [ + {"name": name, "url": f"/api/jobs/{job.id}/stems/{name}.wav"} + for name in found + ] + # Subset post-processing. When the user kept all 6 stems, both of + # these are skipped -- the original is the sum of the stems and a + # mix would equal the original. When a strict subset was chosen: + # - original.wav: complement of the selection (sum of unselected + # stems) so the studio can play it alongside the isolated + # selected stems and reconstruct the full song without doubling. + # - mix.wav: ffmpeg amix of the selected stems for download. + # Update stage so the import-progress UI doesn't keep saying + # "Separating stems..." for the extra ~5-30 s ffmpeg adds. + _check_cancel(job) + _set(job, stage="Mixing tracks...") + original_path = make_original_track(job, job_dir, stems_dir) + if original_path is not None: + job.stems.insert(0, { + "name": "original", + "url": f"/api/jobs/{job.id}/stems/original.wav", + }) + _check_cancel(job) + mix_path = make_selected_mix(job, stems_dir, found) + if mix_path is not None: + # mix_path may point at mix.wav (multi-stem amix) or directly at + # one of the existing stem WAVs (single-stem short-circuit). + # Either way, .name gives us the URL segment. + job.mix_url = f"/api/jobs/{job.id}/stems/{mix_path.name}" + _check_cancel(job) + + +async def run_pipeline(job: Job, url: str, jobs_dir: Path) -> None: + job_dir = jobs_dir / job.id + # One try/except covers everything from directory creation through pipeline + # execution. If anything before the lock raises, the job would otherwise + # stay stuck on `queued` forever -- transition to `error` instead. + try: + job_dir.mkdir(parents=True, exist_ok=True) + # Best-effort sweep of stale jobs before acquiring the pipeline lock so + # disk reclaim happens even while another job is running. + await asyncio.to_thread(sweep_old_jobs, jobs_dir) + async with _pipeline_lock: + await asyncio.to_thread(_run_blocking, job, url, job_dir) + except JobCancelled: + logger.info("pipeline cancelled for job %s", job.id) + _set(job, status="cancelled", stage="Cancelled") + # Drop partial files so the disk reclaim is immediate. + if job_dir.is_dir(): + shutil.rmtree(job_dir, ignore_errors=True) + return + except Exception as e: + # yt-dlp wraps hook exceptions in DownloadError; if the user cancelled + # mid-download the underlying cause is JobCancelled but it arrives here + # as a generic exception. Detect via the flag and route to cancelled. + if job.cancel_requested: + logger.info("pipeline cancelled (wrapped) for job %s", job.id) + _set(job, status="cancelled", stage="Cancelled") + if job_dir.is_dir(): + shutil.rmtree(job_dir, ignore_errors=True) + return + logger.exception("pipeline failed for job %s", job.id) + _set(job, status="error", stage=f"Error: {e}", error=str(e)) + return + _set(job, status="done", progress=1.0, stage="Done") diff --git a/app/pipeline/separate.py b/app/pipeline/separate.py new file mode 100644 index 00000000..364de905 --- /dev/null +++ b/app/pipeline/separate.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import logging +import re +import subprocess +import sys +from pathlib import Path + +from app.core.config import DEMUCS_DEVICE, DEMUCS_MODEL +from app.core.models import Job, JobCancelled +from app.core.registry import set_proc + +logger = logging.getLogger("stemdeck.pipeline") + +_PCT_RE = re.compile(r"(\d{1,3})%") + + +def separate(job: Job, source: Path, job_dir: Path) -> Path: + from app.pipeline.download import _set + + _set(job, status="separating", progress=0.0, stage="Separating stems...") + + cmd = [ + sys.executable, + "-m", + "demucs", + "-n", + DEMUCS_MODEL, + "-d", + DEMUCS_DEVICE, + "-o", + str(job_dir), + str(source), + ] + proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + bufsize=0, + ) + if proc.stderr is None: + raise RuntimeError("demucs subprocess has no stderr pipe") + set_proc(job.id, proc) + + # tqdm uses \r to redraw -- read char-by-char and split on \r or \n. + # Keep the last few non-progress lines so we can surface them if demucs + # exits non-zero (otherwise the only signal would be a bare exit code). + buf = "" + tail: list[str] = [] + try: + while True: + ch = proc.stderr.read(1) + if not ch: + break + if ch in ("\r", "\n"): + line = buf.strip() + buf = "" + if not line: + continue + m = _PCT_RE.search(line) + if m: + pct = max(0, min(100, int(m.group(1)))) + _set(job, progress=pct / 100.0, stage=f"Separating {pct}%") + else: + tail.append(line) + if len(tail) > 40: + tail.pop(0) + else: + buf += ch + + proc.wait() + finally: + set_proc(job.id, None) + + # POST /cancel calls proc.terminate() directly, which causes the read loop + # above to hit EOF and proc.wait() to return a nonzero status. Translate + # that into JobCancelled before the generic "demucs failed" path. + if job.cancel_requested: + raise JobCancelled() + if proc.returncode != 0: + detail = "\n".join(tail[-15:]) if tail else "(no stderr captured)" + logger.error("demucs exited %s; tail:\n%s", proc.returncode, detail) + last = tail[-1] if tail else f"exit status {proc.returncode}" + raise RuntimeError(f"demucs failed: {last}") + + stems_root = job_dir / DEMUCS_MODEL / source.stem + if not stems_root.is_dir(): + raise RuntimeError(f"demucs output not found at {stems_root}") + return stems_root diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 00000000..bb4eb0a0 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,94 @@ +# Two-stage build: +# * builder -- has gcc/build-essential/curl/unzip/pip etc.; produces +# an isolated /opt/venv with all Python deps installed +# and a fetched deno binary. +# * runner -- only the runtime needs: Python (slim), ffmpeg, +# ca-certificates, the venv copied from the builder, +# and our app code. No compilers, no pip cache, no +# download tooling -- shaves ~150-300 MB off the +# single-stage image and reduces attack surface. + +# ─── Stage 1: builder ──────────────────────────────────────────── +FROM python:3.12-slim AS builder + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Build deps for pip wheels that fall back to source compilation +# (numpy/scipy ship manylinux wheels, but smaller deps occasionally +# need a compiler). Plus curl/unzip to fetch deno. +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + ca-certificates \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +# deno -- yt-dlp uses it as the JS runtime for YouTube format +# extraction. Staged here so the runner doesn't need curl/unzip. +ARG TARGETARCH +RUN case "${TARGETARCH:-$(dpkg --print-architecture)}" in \ + amd64) DENO_ARCH=x86_64-unknown-linux-gnu ;; \ + arm64) DENO_ARCH=aarch64-unknown-linux-gnu ;; \ + *) echo "Unsupported arch: ${TARGETARCH}" && exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/deno.zip "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/denoland/deno/releases/latest/download/deno-${DENO_ARCH}.zip" \ + && unzip /tmp/deno.zip -d /usr/local/bin \ + && rm /tmp/deno.zip \ + && /usr/local/bin/deno --version + +# Isolated venv -- the runner stage copies /opt/venv wholesale, +# inheriting all installed deps without dragging in pip caches, +# /var/lib/apt state, or build tools. +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +WORKDIR /app +COPY pyproject.toml ./ +COPY app/ ./app/ +COPY static/ ./static/ +RUN pip install . + +# ─── Stage 2: runner ───────────────────────────────────────────── +FROM python:3.12-slim AS runner + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/opt/venv/bin:$PATH" \ + TORCH_HOME=/cache/torch \ + XDG_CACHE_HOME=/cache + +# Runtime-only OS deps. ffmpeg has many shared libs (libav*, libsw*, +# libpostproc), so we install via apt rather than copy-pasting binaries +# and chasing transitive .so dependencies. ca-certificates is needed +# for HTTPS to YouTube. +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + ffmpeg \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Deno binary fetched in stage 1 (single self-contained file). +COPY --from=builder /usr/local/bin/deno /usr/local/bin/deno + +# Pre-built Python virtualenv with all our deps. The PATH env above +# means `python`, `uvicorn`, `pip`, etc. resolve into the venv with +# no further configuration. +COPY --from=builder /opt/venv /opt/venv + +# Application code -- copied to /app rather than only relied-on via +# site-packages because app/core/config.py derives STATIC_DIR from +# __file__'s parent chain, and the cwd-rooted /app/app version sits +# earlier on sys.path than the venv install of `app`. Without the +# COPY, STATIC_DIR would resolve into site-packages where static/ +# wasn't included by hatchling's wheel target. +WORKDIR /app +COPY app/ ./app/ +COPY static/ ./static/ + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/build/docker-compose.yml b/build/docker-compose.yml new file mode 100644 index 00000000..6b9ec74f --- /dev/null +++ b/build/docker-compose.yml @@ -0,0 +1,27 @@ +# Explicit name so compose doesn't derive it from the parent directory +# (which is now `build/`, giving misleading object names like +# `build_default` for the network). +name: stemdeck + +services: + stemdeck: + # Compose file lives in build/, so the build context is the parent + # (project root) and the Dockerfile sits next to this compose file. + build: + context: .. + dockerfile: build/Dockerfile + image: stemdeck + container_name: stemdeck + ports: + # Bind to loopback only -- StemDeck has no auth and is local-only. + - "127.0.0.1:8000:8000" + volumes: + # Stems and downloaded audio land in /jobs on the + # host so you can grab them without going through the container. + - ../jobs:/app/jobs + # Demucs model weights (~170 MB) survive container rebuilds. + - stemdeck-cache:/cache + restart: unless-stopped + +volumes: + stemdeck-cache: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..856bcd35 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[project] +name = "stemdeck" +version = "0.1.0" +description = "Paste a YouTube URL, get audio stems split into a DAW-style player." +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "yt-dlp>=2024.12.0", + "demucs>=4.0.1", + # Pin torch/torchaudio to 2.6.x — torchaudio 2.7+ removed its built-in + # audio writer and now requires the separate `torchcodec` package, which + # has ABI issues with torch 2.11. Demucs 4.0.1 calls torchaudio.save() to + # write stems, so it breaks on 2.7+. + "torch>=2.6,<2.7", + "torchaudio>=2.6,<2.7", + # torchaudio 2.6 dispatches WAV writes through libsndfile via soundfile. + # Without this, demucs crashes on save with "Couldn't find appropriate + # backend to handle uri ... drums.wav". + "soundfile>=0.12", + # BPM + key analysis on the downloaded source. Pulls numpy/scipy/numba. + "librosa>=0.10", + # ITU-R BS.1770 integrated-loudness (LUFS) measurement for the + # post-analysis loudness card. Pure Python, depends on scipy which + # librosa already pulls in. + "pyloudnorm>=0.1.1", +] + +[project.optional-dependencies] +dev = [ + "ruff>=0.6", + "pytest>=8", + "pytest-asyncio>=0.24", + "httpx>=0.27", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +target-version = "py310" +line-length = 100 +exclude = ["jobs", ".run", "static/vendor"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +ignore = [ + "E501", # line too long -- format-check is the gate, not lint + "B008", # FastAPI uses Depends(...) in defaults + "SIM105", # try/except/pass is fine; contextlib.suppress is heavier for one-shot warm-up imports +] + +[tool.ruff.lint.isort] +split-on-trailing-comma = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..fed2488b --- /dev/null +++ b/run.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# stemdeck dev server control: setup | start | stop | restart | status + +set -euo pipefail + +cd "$(dirname "$0")" + +HOST="${HOST:-127.0.0.1}" +PORT="${PORT:-8000}" +RELOAD="${RELOAD:-0}" +FOREGROUND="${FOREGROUND:-0}" +PID_FILE=".run/uvicorn.pid" +LOG_FILE=".run/uvicorn.log" +UVICORN=".venv/bin/uvicorn" + +mkdir -p .run + +is_running() { + [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null +} + +start() { + if is_running; then + echo "already running (pid $(cat "$PID_FILE"))" + return 0 + fi + if [[ ! -x "$UVICORN" ]]; then + echo "uvicorn not found at $UVICORN — run: uv sync" >&2 + exit 1 + fi + echo "starting on http://$HOST:$PORT" + local args=(app.main:app --host "$HOST" --port "$PORT") + if [[ "$RELOAD" == "1" ]]; then + args+=(--reload) + fi + if [[ "$FOREGROUND" == "1" ]]; then + echo $$ >"$PID_FILE" + exec "$UVICORN" "${args[@]}" + fi + nohup "$UVICORN" "${args[@]}" >"$LOG_FILE" 2>&1 & + echo $! >"$PID_FILE" + sleep 1 + if is_running; then + echo "started (pid $(cat "$PID_FILE"), log: $LOG_FILE)" + else + echo "failed to start — see $LOG_FILE" >&2 + exit 1 + fi +} + +stop() { + if ! is_running; then + echo "not running" + # also sweep any stray uvicorn for this app + pkill -f "uvicorn app.main:app" 2>/dev/null || true + rm -f "$PID_FILE" + return 0 + fi + local pid + pid=$(cat "$PID_FILE") + echo "stopping pid $pid" + kill "$pid" 2>/dev/null || true + for _ in {1..10}; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + if kill -0 "$pid" 2>/dev/null; then + echo "force-killing pid $pid" + kill -9 "$pid" 2>/dev/null || true + fi + # kill any in-flight demucs children spawned by the app + pkill -f "python -m demucs" 2>/dev/null || true + rm -f "$PID_FILE" + echo "stopped" +} + +status() { + if is_running; then + echo "running (pid $(cat "$PID_FILE")) on http://$HOST:$PORT" + else + echo "not running" + fi +} + +setup() { + local os + case "$(uname -s)" in + Darwin) os="macos" ;; + Linux) os="linux" ;; + *) + echo "setup: unsupported OS '$(uname -s)' — install ffmpeg + uv manually" >&2 + exit 1 + ;; + esac + echo "detected: $os" + + if [[ "$os" == "macos" ]]; then + if ! command -v brew >/dev/null 2>&1; then + echo "setup: Homebrew not found — install from https://brew.sh first" >&2 + exit 1 + fi + if ! command -v ffmpeg >/dev/null 2>&1; then + echo "==> brew install ffmpeg" + brew install ffmpeg + else + echo "ffmpeg: already installed ($(ffmpeg -version | head -n1))" + fi + if ! command -v uv >/dev/null 2>&1; then + echo "==> brew install uv" + brew install uv + else + echo "uv: already installed ($(uv --version))" + fi + else + # linux: assume Debian/Ubuntu (apt). Other distros fall through to manual. + if ! command -v apt-get >/dev/null 2>&1; then + echo "setup: non-apt Linux detected — install ffmpeg + uv via your package manager" >&2 + exit 1 + fi + if ! command -v ffmpeg >/dev/null 2>&1; then + echo "==> sudo apt-get update && sudo apt-get install -y ffmpeg" + sudo apt-get update + sudo apt-get install -y ffmpeg + else + echo "ffmpeg: already installed ($(ffmpeg -version | head -n1))" + fi + if ! command -v uv >/dev/null 2>&1; then + echo "==> installing uv via astral.sh installer" + curl -LsSf https://astral.sh/uv/install.sh | sh + # installer drops uv in ~/.local/bin; surface it for this shell + export PATH="$HOME/.local/bin:$PATH" + else + echo "uv: already installed ($(uv --version))" + fi + fi + + echo "==> uv sync" + uv sync + + echo + echo "setup complete. start the server with: ./run.sh start" +} + +case "${1:-}" in + setup) setup ;; + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) status ;; + *) + echo "usage: $0 {setup|start|stop|restart|status}" >&2 + exit 2 + ;; +esac diff --git a/static/css/appbar.css b/static/css/appbar.css new file mode 100644 index 00000000..4375699b --- /dev/null +++ b/static/css/appbar.css @@ -0,0 +1,253 @@ +/* ───────────── Top bar ───────────── */ + +.appbar { + display: grid; + grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); + align-items: stretch; + gap: 22px; + padding: 20px 24px 22px; + border: 1px solid var(--glass-line); + border-radius: 16px; + background: + radial-gradient(circle at 82% -20%, rgba(216, 168, 74, 0.11), transparent 20rem), + linear-gradient(180deg, rgba(27, 39, 48, 0.76), rgba(13, 22, 30, 0.84)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + var(--shadow-panel); +} + +.brand-group { + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + min-width: 0; + padding-right: 8px; +} + +.brand-row { + display: flex; + align-items: center; + gap: 8px; +} + +.brand { + margin: 0; + font-family: var(--font-mono); + font-weight: 700; + font-size: 24px; + line-height: 1; + color: var(--ink); + letter-spacing: -0.01em; +} + +.beta-badge { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 5px; + border: 1px solid rgba(216, 168, 74, 0.65); + border-radius: 6px; + background: var(--gold-soft); + color: var(--gold-bright); + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.brand-sub { + font-size: 13px; + color: var(--ink-dim); + white-space: nowrap; +} + +.post-urlbar, +.post-process-btn { + display: none; +} + +.appbar-form { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +.appbar-import-panel { + min-width: 0; +} + +.appbar-import-panel .import-input-row { + grid-template-columns: minmax(0, 1fr) minmax(156px, 220px); + gap: 28px; + padding-bottom: 22px; +} + +.appbar-import-panel .url-wrap { + min-height: 58px; + padding: 0 20px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 11px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent), + rgba(3, 9, 14, 0.72); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.035), + inset 0 -1px 0 rgba(0, 0, 0, 0.32), + 0 0 0 0 rgba(216, 168, 74, 0); + transition: + border-color var(--t-base) var(--easing), + box-shadow var(--t-base) var(--easing), + background-color var(--t-base) var(--easing); +} + +.appbar-import-panel .url-wrap:focus-within { + border-color: rgba(216, 168, 74, 0.56); + background-color: rgba(6, 13, 19, 0.86); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + inset 0 -1px 0 rgba(0, 0, 0, 0.34), + 0 0 0 3px rgba(216, 168, 74, 0.08), + 0 14px 34px rgba(0, 0, 0, 0.22); +} + +.appbar-import-panel .url-prefix { + color: #cbd2dc; + opacity: 0.92; +} + +.appbar-import-panel input[type="url"] { + font-size: clamp(15px, 0.95vw, 18px); + height: 100%; + color: #edf0f4; + caret-color: var(--gold-bright); + letter-spacing: 0; +} + +.appbar-import-panel .btn-primary { + height: 58px; + min-width: 0; + width: 100%; + border-radius: 11px; + font-size: clamp(14px, 0.9vw, 16px); +} + +.appbar-import-panel .stem-choice-row { + grid-template-columns: 156px repeat(6, minmax(92px, 1fr)); + gap: 14px; + padding-top: 20px; +} + +.appbar-import-panel .stem-choice-row > span { + font-size: clamp(12px, 0.8vw, 14px); +} + +.appbar-import-panel .stem-choice { + min-height: 48px; + padding: 0 14px; + border-radius: 10px; + font-size: clamp(12px, 0.8vw, 14px); + background: + linear-gradient(180deg, color-mix(in srgb, currentColor 9%, transparent), transparent), + rgba(8, 15, 22, 0.42); +} + +.url-wrap { + display: flex; + align-items: center; + gap: 16px; + flex: 1; + min-width: 0; + cursor: text; +} + +.url-prefix { + flex-shrink: 0; + color: #d7dbe2; +} + +.appbar-form input[type="url"], +.appbar-import-panel input[type="url"], +.import-card input[type="url"] { + flex: 1; + background: transparent; + border: 0; + outline: 0; + color: var(--ink); + font-family: var(--font-mono); + font-size: 19px; + padding: 0; + min-width: 0; + appearance: none; + box-shadow: none; +} + +.appbar-form input[type="url"]::placeholder, +.appbar-import-panel input[type="url"]::placeholder, +.import-card input[type="url"]::placeholder { + color: #9da3ad; + opacity: 0.78; +} + +.appbar-import-panel input[type="url"]::selection { + background: rgba(216, 168, 74, 0.28); + color: #fff6de; +} + +.appbar-import-panel input[type="url"] { + font-size: clamp(15px, 0.95vw, 18px); + color: #edf0f4; +} + +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: 56px; + min-width: 150px; + background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%); + color: #17130c; + border: 0; + border-radius: var(--radius); + padding: 0 24px; + font-family: var(--font-mono); + font-size: 16px; + font-weight: 700; + cursor: pointer; + position: relative; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.38), + 0 16px 36px rgba(216, 168, 74, 0.18); + transition: + filter var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.btn-primary:hover:not(:disabled) { + filter: brightness(1.08); +} + +.btn-primary:active:not(:disabled) { + transform: scale(0.98); +} + +.btn-primary:disabled { + opacity: 0.82; + cursor: progress; +} + +.btn-primary.loading { + filter: saturate(0.92); +} + +.btn-primary.loading .icon { + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 00000000..2e5bac54 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,110 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: + radial-gradient(circle at 50% 0%, rgba(216, 168, 74, 0.1), transparent 34rem), + radial-gradient(circle at 0% 45%, rgba(216, 168, 74, 0.055), transparent 28rem), + radial-gradient(circle at 100% 45%, rgba(216, 168, 74, 0.055), transparent 28rem), + linear-gradient(180deg, #081016 0%, #050a0f 100%); + color: var(--ink); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.hidden { + display: none !important; +} + +:focus { + outline: 0; +} +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; + border-radius: var(--radius-xs); +} + +.surface-panel { + border: 1px solid var(--glass-line); + background: + linear-gradient(180deg, rgba(28, 39, 48, 0.82), rgba(15, 23, 31, 0.86)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + var(--shadow-panel); +} + +.app { + max-width: 1640px; + margin: 0 auto; + min-height: 100vh; + padding: 24px 28px 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.app:not(.is-import) { + max-width: none; + width: 100%; + height: 100vh; + min-height: 0; + padding: 20px 29px 0; + gap: 12px; + overflow: hidden; +} + +.app.is-import .now-playing, +.app.is-import #lanes { + display: none; +} + +.app:not(.is-import) .import-page, +.app:not(.is-import) .site-footer { + display: none; +} + +.site-footer { + display: flex; + align-items: center; + gap: 24px; + margin-top: auto; + padding: 18px 14px 22px; + color: #8d929a; + font-size: 14px; +} + +.site-footer > span { + margin-right: auto; +} + +.site-footer nav { + display: flex; + align-items: center; + gap: 26px; +} + +.site-footer a { + color: inherit; + text-decoration: none; +} + +.site-footer a:hover { + color: var(--ink); +} + +.help-btn { + height: 42px; + padding: 0 16px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); + background: rgba(18, 26, 34, 0.75); + color: var(--ink); + font: inherit; +} diff --git a/static/css/job.css b/static/css/job.css new file mode 100644 index 00000000..3b0fd29a --- /dev/null +++ b/static/css/job.css @@ -0,0 +1,124 @@ +/* ───────────── Job progress ───────────── */ + +.job { + display: none !important; + background: var(--panel); + border: var(--border-w) solid var(--line); + border-radius: var(--radius); + padding: 14px 16px; +} +.job-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 10px; +} +#job-title { + font-size: 14px; + flex: 1; + word-break: break-word; +} +#job-stage { + font-size: 13px; + color: var(--ink-dim); + white-space: nowrap; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} +progress { + width: 100%; + height: 4px; + appearance: none; + background: var(--line); + border: 0; + border-radius: 2px; + overflow: hidden; +} +progress::-webkit-progress-bar { + background: var(--line); +} +progress::-webkit-progress-value { + background: var(--ink); + transition: width var(--t-base) var(--easing); +} +progress::-moz-progress-bar { + background: var(--ink); +} + +/* Compact technical detail line under the progress bar -- shows the + truthful backend `stage` value while #job-stage rotates phrases. */ +.job-detail { + margin-top: 6px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-dim); + opacity: 0.65; + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cancel-btn { + flex-shrink: 0; + background: transparent; + color: var(--ink-dim); + border: var(--border-w) solid var(--line); + border-radius: var(--radius-xs); + padding: 4px 10px; + font: inherit; + font-family: var(--font-mono); + font-size: 11px; + cursor: pointer; + transition: + background-color var(--t-base) var(--easing), + color var(--t-base) var(--easing), + border-color var(--t-base) var(--easing); +} +.cancel-btn:hover:not(:disabled) { + background: var(--danger); + color: var(--bg); + border-color: var(--danger); +} +.cancel-btn:disabled { + opacity: 0.5; + cursor: default; +} + +/* ───────────── Error ───────────── */ + +.error { + background: rgba(214, 90, 74, 0.1); + border: var(--border-w) solid rgba(214, 90, 74, 0.5); + color: var(--danger); + border-radius: var(--radius); + padding: 12px 16px; + display: flex; + align-items: flex-start; + gap: 12px; +} +.error-msg { + flex: 1; + word-break: break-word; + font-size: 13px; +} +.retry-btn { + flex-shrink: 0; + background: transparent; + color: var(--danger); + border: var(--border-w) solid var(--danger); + border-radius: var(--radius-xs); + padding: 5px 12px; + font: inherit; + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + transition: + background-color var(--t-base) var(--easing), + color var(--t-base) var(--easing); +} +.retry-btn:hover { + background: var(--danger); + color: var(--bg); +} diff --git a/static/css/master.css b/static/css/master.css new file mode 100644 index 00000000..9b84342e --- /dev/null +++ b/static/css/master.css @@ -0,0 +1,106 @@ +/* ───────────── Master fader + VU ───────────── + * Console-style: tick-marked vertical fader on the left, twin segmented + * LED VU meters on the right, both inside a recessed dark panel. + * Custom rendering throughout — `appearance: none` so the browser + * doesn't fall back to its blue-track default vertical slider, which + * ignores ::-webkit-slider-thumb overrides and offsets the thumb. */ + +.np-master { + display: flex; + align-items: stretch; + gap: 10px; + height: 140px; + padding: 8px 10px; + background: linear-gradient(180deg, #141414, #0d0d0d); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.4); +} + +/* Fader column with tick marks down both sides. */ +.master-fader-track { + position: relative; + height: 100%; + width: 44px; + display: flex; + align-items: stretch; + justify-content: center; +} +.master-fader-track::before, +.master-fader-track::after { + content: ""; + position: absolute; + top: 4px; + bottom: 4px; + width: 5px; + background-image: repeating-linear-gradient( + to bottom, + var(--line-strong) 0, + var(--line-strong) 1px, + transparent 1px, + transparent 8px + ); + pointer-events: none; +} +.master-fader-track::before { left: 4px; } +.master-fader-track::after { right: 4px; } + +.master-fader { + /* No `appearance: slider-vertical` — that's a legacy webkit shortcut + that forces a blue-track default vertical slider AND ignores + ::-webkit-slider-thumb overrides (which is why the thumb floats + left of center). `writing-mode: vertical-lr` rotates the + horizontal range input, `direction: rtl` puts max at the top. */ + -webkit-appearance: none; + appearance: none; + -moz-appearance: none; + writing-mode: vertical-lr; + direction: rtl; + width: 100%; + height: 100%; + background: transparent; + cursor: pointer; + outline: none; + margin: 0; + padding: 0; +} +.master-fader::-webkit-slider-runnable-track { + width: 4px; + height: 100%; + background: #050505; + border: 1px solid var(--line); + border-radius: 2px; +} +.master-fader::-moz-range-track { + width: 4px; + height: 100%; + background: #050505; + border: 1px solid var(--line); + border-radius: 2px; +} +.master-fader::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + /* In writing-mode: vertical-lr, `width` is the long (horizontal) axis + and `height` is the thin (vertical) axis. Pill-shaped horizontal + handle. margin-left: -12px centers it on the 4px track. */ + width: 28px; + height: 14px; + margin-left: -12px; + background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%); + border: 1px solid #050505; + border-radius: 3px; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + inset 0 -1px 0 rgba(0, 0, 0, 0.7), + 0 1px 2px rgba(0, 0, 0, 0.7); + cursor: pointer; +} +.master-fader::-moz-range-thumb { + width: 28px; + height: 14px; + background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%); + border: 1px solid #050505; + border-radius: 3px; + cursor: pointer; +} diff --git a/static/css/mixer.css b/static/css/mixer.css new file mode 100644 index 00000000..65db16f0 --- /dev/null +++ b/static/css/mixer.css @@ -0,0 +1,418 @@ +/* ───────────── Preview mixer ───────────── */ + +.mixer-column { + min-width: 0; + display: grid; + grid-template-columns: repeat(6, minmax(68px, 1fr)); + gap: 10px; +} + +.lane-header { + min-height: 190px; + display: grid; + grid-template-rows: auto 1fr; + justify-items: center; + position: relative; + transition: opacity var(--t-base) var(--easing); + color: var(--stem-color, #d8dde4); +} + +.lane-header[data-stem="vocals"] { --stem-color: #e54249; } +.lane-header[data-stem="drums"] { --stem-color: #f06a14; } +.lane-header[data-stem="bass"] { --stem-color: #eab414; } +.lane-header[data-stem="guitar"] { --stem-color: #5bbf50; } +.lane-header[data-stem="piano"] { --stem-color: #7f45d8; } +.lane-header[data-stem="other"] { --stem-color: #3e8dcf; } +.lane-header[data-stem="original"] { --stem-color: #a8b0bd; } + +.lane-header + .lane-header { + border-left: 1px solid var(--glass-line); +} + +.lane-header.muted { + opacity: 0.45; +} + +.lane-stripe { + display: none; +} + +.lane-content { + display: contents; +} + +.lane-name-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; +} + +.lane-icon { + width: 29px; + height: 29px; + flex: 0 0 auto; + color: var(--stem-color); + filter: drop-shadow(0 0 10px color-mix(in srgb, currentColor 34%, transparent)); +} + +.lane-icon-toggle { + width: 42px; + height: 42px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--stem-color); + cursor: pointer; + opacity: 0.38; + transition: + opacity var(--t-base) var(--easing), + border-color var(--t-base) var(--easing), + background-color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.lane-icon-toggle.active { + opacity: 1; + border-color: color-mix(in srgb, currentColor 24%, transparent); + background: color-mix(in srgb, currentColor 8%, transparent); + box-shadow: inset 0 0 18px color-mix(in srgb, currentColor 8%, transparent); +} + +.lane-icon-toggle:hover:not(:disabled) { + opacity: 1; + transform: translateY(-1px); +} + +.lane-icon-toggle:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 2px; +} + +.lane-icon-toggle:disabled { + cursor: default; + opacity: 0.25; +} + +.lane-name { + color: #d7dce3 !important; + font-size: 14px; +} + +.lane-controls { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 106px; + align-items: end; + justify-items: center; + gap: 10px; +} + +.lane-mini-wave { + display: none; +} + +.lane-dl { + display: none; + color: #d8dde4; + text-decoration: none; +} + +.lane-knob { + grid-column: 1 / -1; + grid-row: 1; + width: 48px; + height: 104px; + border-radius: 0; + border: 0; + background: + repeating-linear-gradient(to bottom, rgba(148, 163, 184, 0.22) 0 1px, transparent 1px 12px), + linear-gradient(90deg, transparent 0 44%, rgba(148, 163, 184, 0.2) 44% 56%, transparent 56% 100%); + position: relative; + cursor: ns-resize; + /* --lane-pos: 0..1 = bottom..top. Default 0.5 = unity gain (vol=1 + of LANE_VOLUME_MAX=2). JS rewrites this when volume changes. */ + --lane-pos: 0.5; +} + +/* Fader thumb. Travels vertically along the track based on --lane-pos. + The travel envelope is 100% - 18px (the thumb's own height) so the + thumb never overshoots the track edges. The transition is only for + programmatic moves (Reset, dblclick, wheel) -- during an active + drag we kill the transition (.lane-knob.dragging rule below) so the + thumb tracks the cursor 1:1 instead of chasing it through an + easing curve, which reads as a rigid/laggy fader. */ +.lane-knob::before { + content: ""; + position: absolute; + left: 50%; + top: calc((1 - var(--lane-pos)) * (100% - 14px)); + width: 30px; + height: 14px; + transform: translateX(-50%); + border-radius: 3px; + border: 1px solid #050505; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.2), transparent 42%), + linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + inset 0 -1px 0 rgba(0, 0, 0, 0.7), + 0 0 0 1px color-mix(in srgb, currentColor 34%, transparent), + 0 1px 2px rgba(0, 0, 0, 0.72); + transition: top 120ms var(--easing); + will-change: top; + pointer-events: none; +} + +.lane-knob.dragging::before { + transition: none; +} + +/* Slight grip cue while actively dragging -- thumb gets a tighter + shadow ring so it feels "picked up". */ +.lane-knob.dragging { + cursor: grabbing; +} + +.lane-knob-indicator { + display: none; +} + +.lane-knob.disabled { + opacity: 0.45; + pointer-events: none; +} + +.ms-btn { + width: 30px; + height: 30px; + border: 0; + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: #d8dde4; + font-family: var(--font-mono); + font-size: 14px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: + background-color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.ms-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.ms-btn:active { + transform: scale(0.92); +} + +.ms-btn.mute.active { + background: var(--danger); + color: white; +} + +.ms-btn.solo.active { + background: var(--gold); + color: #17130c; +} + +.ms-btn:disabled { + opacity: 0.38; + pointer-events: none; +} + +.lane-controls .ms-btn.mute { + grid-column: 1; + grid-row: 2; +} + +.lane-controls .ms-btn.solo { + grid-column: 2; + grid-row: 2; +} + +.lane-vu { + display: none; +} + +.app:not(.is-import) .preview-mixer-body { + height: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0; + padding-top: 0; +} + +.app:not(.is-import) .mixer-column { + height: 100%; + grid-template-columns: repeat(var(--visible-track-count, 6), minmax(52px, 1fr)); + gap: 0; +} + +.app:not(.is-import) .lane-header { + min-height: 0; + height: 100%; + grid-template-rows: 64px 1fr; + padding: 7px 0 6px; + overflow: hidden; +} + +.app:not(.is-import) .lane-name-row { + gap: 6px; +} + +.app:not(.is-import) .lane-icon { + width: 26px; + height: 26px; +} + +.app:not(.is-import) .lane-icon-toggle { + width: 38px; + height: 38px; +} + +.app:not(.is-import) .lane-name { + font-size: 12px; +} + +.app:not(.is-import) .lane-controls { + width: 100%; + height: auto; + min-height: 0; + align-self: stretch; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + gap: 0; + padding: 0 5px 38px; +} + +.app:not(.is-import) .lane-knob { + width: 42px; + height: 100%; + min-height: 0; + justify-self: center; +} + +.app:not(.is-import) .lane-vu { + display: block; + position: absolute; + left: 50%; + top: 72px; + bottom: 48px; + width: 12px; + height: auto; + transform: translateX(18px); + border-radius: 2px; + overflow: hidden; + background: + linear-gradient(to top, + rgba(48, 197, 87, 0.14) 0 55%, + rgba(225, 178, 46, 0.14) 55% 80%, + rgba(214, 64, 64, 0.14) 80% 100%), + rgba(4, 10, 15, 0.82); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12); +} + +.app:not(.is-import) .lane-dl { + display: grid; + place-items: center; + position: absolute; + left: 8px; + right: 8px; + bottom: 6px; + height: 34px; + transform: none; + border: 1px solid color-mix(in srgb, var(--stem-color) 28%, rgba(148, 163, 184, 0.18)); + border-radius: 8px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 13%, transparent), transparent), + rgba(255, 255, 255, 0.055); + color: var(--stem-color); + opacity: 0.9; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.035), + 0 6px 14px rgba(0, 0, 0, 0.16); + transition: + opacity var(--t-base) var(--easing), + background-color var(--t-base) var(--easing), + border-color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.app:not(.is-import) .lane-dl svg { + width: 19px; + height: 19px; + stroke-width: 2.15; +} + +.app:not(.is-import) .lane-dl:hover:not(.disabled) { + opacity: 1; + border-color: color-mix(in srgb, var(--stem-color) 58%, transparent); + background: + linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 18%, transparent), transparent), + rgba(255, 255, 255, 0.075); + transform: translateY(-1px); +} + +.app:not(.is-import) .lane-dl:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 2px; +} + +.app:not(.is-import) .lane-dl.disabled { + opacity: 0.28; + pointer-events: none; +} + +.app:not(.is-import) .lane-vu::before { + content: ""; + position: absolute; + left: 1px; + right: 1px; + top: 1px; + bottom: 1px; + background: + repeating-linear-gradient(to top, transparent 0 3px, rgba(0, 0, 0, 0.4) 3px 5px), + linear-gradient(to top, #24bf55 0 58%, #e1b22e 58% 80%, #d64040 80% 100%); + clip-path: inset(calc(100% - var(--vu-level, 0%)) 0 0 0); + transition: clip-path 30ms linear; +} + +.app:not(.is-import) .lane-vu::after { + content: ""; + position: absolute; + left: 1px; + right: 1px; + bottom: var(--vu-peak, 0%); + height: 2px; + background: rgba(255, 255, 255, 0.78); + transition: bottom 80ms linear; +} + +.app:not(.is-import) .master-strip { + height: 100%; + min-height: 0; + grid-template-columns: 1fr; + grid-template-rows: 72px 1fr; + gap: 8px; + overflow: hidden; +} + +.app:not(.is-import) .master-strip .master-fader-track { + height: calc(100% - 4px); + min-height: 0; +} + +.app:not(.is-import) .master-strip .master-fader-track::before, +.app:not(.is-import) .master-strip .master-fader-track::after { + display: none; +} diff --git a/static/css/responsive.css b/static/css/responsive.css new file mode 100644 index 00000000..f24240e7 --- /dev/null +++ b/static/css/responsive.css @@ -0,0 +1,308 @@ +/* ───────────── Responsive ───────────── + * Breakpoints: + * ≤1100px — laptop / portrait tablet: now-playing card reflows to 3-col + * with master spanning full width below transport. + * ≤900px — landscape phone / small tablet: appbar wraps, lanes scroll + * horizontally with a sticky mixer column, master compresses. + * ≤640px — phone: mixer column narrower, brand smaller, transport + * buttons full-width touch-friendly, master inline-compact. + * ≤480px — small phone: tightest layout, art shrinks, paddings minimal. + * pointer:coarse — touch input regardless of screen size: bump + * interactive targets to a comfortable hit area. + */ + +@media (max-width: 1100px) { + .import-card { + width: min(100%, 900px); + } + .stem-choice-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .stem-choice-row > span { + grid-column: 1 / -1; + } + .appbar { + grid-template-columns: 1fr; + } + .appbar-import-panel .import-input-row { + grid-template-columns: minmax(0, 1fr) minmax(144px, 180px); + } + .appbar-import-panel .stem-choice-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .appbar-import-panel .stem-choice-row > span { + grid-column: 1 / -1; + } + .feature-strip { + grid-template-columns: 1fr; + gap: 22px; + } + .feature-item + .feature-item { + border-left: 0; + border-top: 1px solid var(--gold-line); + padding-left: 0; + padding-top: 22px; + } + .now-playing { + grid-template-columns: auto 1fr auto; + grid-template-areas: + "art info transport" + "art master master"; + gap: 14px 16px; + } + .np-art { grid-area: art; } + .np-info { grid-area: info; } + .np-transport { grid-area: transport; } + .np-master { grid-area: master; } +} + +@media (max-width: 900px) { + .app { + padding: 16px; + gap: 12px; + } + .appbar { + gap: 12px; + } + .import-page { + padding: 54px 18px 20px; + } + .import-copy h2 { + font-size: 31px; + } + .import-card { + margin-top: 36px; + padding: 18px; + } + .import-input-row { + grid-template-columns: 1fr; + } + .appbar-import-panel { + padding: 16px; + } + .appbar-import-panel .import-input-row { + grid-template-columns: 1fr; + gap: 12px; + } + .btn-primary { + width: 100%; + } + .brand-group { + flex: 1; + } + .appbar-form { + order: 3; + width: 100%; + flex: none; + } + /* Compress the master section to recover vertical space — the full + 140px panel makes the now-playing card too tall when stacked. */ + .np-master { + height: 90px; + padding: 6px 8px; + } + /* Lane scrolling: mixer column stays put, waves scroll under it. */ + .lanes-body, + .lanes-ruler { + overflow-x: auto; + } + .mixer-column, + .lanes-ruler-gutter { + position: sticky; + left: 0; + z-index: 4; + background: var(--panel); + } + /* Keep the waves area readable when scrolling — without a min-width + the waveform canvases collapse below ~250px and become unusable. */ + .waves-column, + .lanes-ruler-time { + min-width: 480px; + } +} + +@media (max-width: 640px) { + :root { + --header-w: 200px; + } + .brand { + font-size: 22px; + } + .brand-sub { + font-size: 12px; + } + .import-copy h2 { + font-size: 26px; + } + .import-copy p { + font-size: 15px; + } + .stem-choice-row { + grid-template-columns: 1fr 1fr; + } + .feature-strip { + padding: 22px; + } + .feature-item { + grid-template-columns: 52px 1fr; + } + .now-playing { + grid-template-columns: auto 1fr; + grid-template-areas: + "art info" + "transport master"; + gap: 12px; + } + /* Inline transport + master side-by-side rather than stacked, so the + card stays compact. Master is already compressed by the 900px rule. */ + .np-master { + height: 80px; + } + .np-transport { + align-self: center; + } + .lane-mini-wave { + display: none; /* save horizontal space in the mixer column */ + } + .np-title { + font-size: 14px; + -webkit-line-clamp: 3; + } +} + +@media (max-width: 480px) { + :root { + --header-w: 180px; + --lane-h: 84px; + } + .app { + padding: 12px; + gap: 10px; + } + /* Single-column stem buttons; the 2-col grid still squeezes labels at + ~360px because each button needs the icon + text + 8px gap. */ + .stem-choice-row { + grid-template-columns: 1fr; + } + .stem-choice { + min-height: 44px; + } + .feature-strip { + margin-top: 24px; + } + .privacy-note { + margin-top: 22px; + font-size: 13px; + text-align: center; + } + .now-playing { + padding: 10px 12px; + grid-template-columns: auto 1fr; + grid-template-areas: + "art info" + "transport transport" + "master master"; + gap: 10px; + } + .np-art { + width: 56px; + height: 56px; + } + .np-art-play { + width: 28px; + height: 28px; + } + .np-master { + height: 72px; + } + .np-transport { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + .chip { + padding: 3px 8px; + font-size: 12px; + } + /* Drop the second chip column to one row of just the essentials */ + .np-chips { + gap: 6px; + } + /* Mixer rows: tighten internal padding so M/S/knob/cloud fit. */ + .lane-content { + padding: 8px 36px 8px 8px; + } + .lane-name { + font-size: 13px; + } + /* Tighten lane VU on phone so the row stays readable. */ + .lane-vu { + width: 18px; + right: 4px; + } + /* Keep waves usable — lower the min-width slightly on tiny screens. */ + .waves-column, + .lanes-ruler-time { + min-width: 360px; + } +} + +/* Touch-input devices: enlarge interactive targets to ≥40px hit area + regardless of screen size. Desktop stays compact. */ +@media (pointer: coarse) { + .ms-btn { + width: 40px; + height: 40px; + font-size: 13px; + } + .lane-knob { + width: 40px; + height: 40px; + } + .lane-knob-indicator { + transform-origin: 50% 16px; + height: 11px; + top: 5px; + } + .lane-dl { + width: 36px; + height: 36px; + } + .btn-transport { + width: 44px; + height: 44px; + } + .avatar-btn { + width: 44px; + height: 44px; + } + /* Bigger master fader thumb so it's easier to grab. */ + .master-fader::-webkit-slider-thumb { + width: 32px; + height: 18px; + margin-left: -14px; + } + .master-fader::-moz-range-thumb { + width: 32px; + height: 18px; + } +} + +/* Landscape orientation on short screens (phones held sideways): the + now-playing card eats too much vertical space if it tries to stack. + Prefer a single horizontal row. */ +@media (max-height: 500px) and (orientation: landscape) { + .now-playing { + grid-template-columns: auto 1fr auto auto; + grid-template-areas: "art info transport master"; + gap: 10px; + padding: 8px 12px; + } + .np-master { + height: 70px; + } + .np-art { + width: 56px; + height: 56px; + } +} diff --git a/static/css/transport.css b/static/css/transport.css new file mode 100644 index 00000000..250c114b --- /dev/null +++ b/static/css/transport.css @@ -0,0 +1,616 @@ +/* ───────────── Import page ───────────── */ + +.import-page { + min-height: clamp(420px, 60vh, 704px); + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + padding: clamp(28px, 6vw, 78px) clamp(14px, 3vw, 24px) clamp(18px, 3vw, 24px); + border: 1px solid var(--glass-line); + border-radius: 12px; + background: + radial-gradient(circle at 50% 22%, rgba(216, 168, 74, 0.1), transparent 23rem), + radial-gradient(circle at 8% 45%, rgba(216, 168, 74, 0.06), transparent 22rem), + radial-gradient(circle at 92% 45%, rgba(216, 168, 74, 0.06), transparent 22rem), + linear-gradient(180deg, rgba(16, 26, 34, 0.78), rgba(8, 15, 22, 0.88)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); +} + +.import-copy { + text-align: center; + position: relative; + z-index: 2; +} + +.import-copy h2 { + margin: 0; + color: #f1f3f5; + font-size: clamp(24px, 3.4vw + 8px, 39px); + line-height: 1.12; + font-weight: 700; +} + +.import-copy h2 span { + color: var(--gold-bright); + white-space: nowrap; +} + +.import-copy p { + margin: 16px 0 0; + color: #a9b0bb; + font-size: clamp(14px, 1vw + 8px, 18px); +} + +.import-card { + width: min(100%, 1088px); + margin-top: clamp(20px, 4vw, 58px); + padding: clamp(14px, 2vw, 19px) clamp(16px, 3vw, 36px) clamp(18px, 2.5vw, 28px); + border-radius: 13px; + position: relative; + z-index: 2; +} + +.import-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 24px; + align-items: center; + padding-bottom: 18px; + border-bottom: 1px solid var(--glass-line); +} + +.stem-choice-row { + display: grid; + grid-template-columns: 138px repeat(6, minmax(86px, 1fr)); + gap: 14px; + align-items: center; + padding-top: 22px; +} + +.stem-choice-row > span { + color: #d9dde4; + font-weight: 600; + white-space: nowrap; +} + +.stem-choice { + min-height: 54px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent), + rgba(8, 15, 22, 0.34); + color: currentColor; + font: inherit; + font-weight: 700; + transition: + border-color var(--t-base) var(--easing), + background-color var(--t-base) var(--easing), + box-shadow var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.stem-choice:hover { + transform: translateY(-1px); + background-color: rgba(255, 255, 255, 0.045); + box-shadow: inset 0 0 22px color-mix(in srgb, currentColor 10%, transparent); +} + +/* Deselected state -- the user has unchecked this stem from the + "stems to extract" set, so it's greyed out and won't appear in the + studio dashboard after processing. Maintains layout (still occupies + its grid slot) so the row doesn't reflow on toggle. */ +.stem-choice[aria-pressed="false"] { + opacity: 0.32; + border-color: rgba(148, 163, 184, 0.2) !important; + background: rgba(8, 15, 22, 0.2); +} + +.stem-choice[aria-pressed="false"]:hover { + opacity: 0.55; +} + +.stem-choice svg { + flex: 0 0 auto; +} + +.stem-choice.vocals { color: #ff3f46; border-color: rgba(255, 63, 70, 0.28); } +.stem-choice.drums { color: #ff7a1a; border-color: rgba(255, 122, 26, 0.24); } +.stem-choice.bass { color: #ffd04d; border-color: rgba(255, 208, 77, 0.24); } +.stem-choice.guitar { color: #3fc952; border-color: rgba(63, 201, 82, 0.24); } +.stem-choice.piano { color: #974cff; border-color: rgba(151, 76, 255, 0.24); } +.stem-choice.other { color: #3695ff; border-color: rgba(54, 149, 255, 0.24); } + +.privacy-note { + display: inline-flex; + align-items: center; + gap: 9px; + margin-top: 34px; + color: #a6adb7; + font-size: 15px; + position: relative; + z-index: 2; +} + +.feature-strip { + width: min(100%, 1160px); + min-height: 136px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: center; + gap: clamp(18px, 2.4vw, 36px); + margin-top: clamp(22px, 4vw, 54px); + padding: clamp(16px, 2.4vw, 24px) clamp(18px, 3vw, 46px); + border-radius: 13px; + position: relative; + z-index: 2; +} + +.feature-item { + display: grid; + grid-template-columns: clamp(40px, 4vw, 68px) 1fr; + align-items: center; + gap: clamp(12px, 1.5vw, 24px); + color: var(--gold); +} + +.feature-item svg { + width: 100%; + height: auto; + max-width: 48px; +} + +.feature-item + .feature-item { + border-left: 1px solid var(--gold-line); + padding-left: clamp(18px, 2.4vw, 42px); +} + +.feature-item strong { + display: block; + color: var(--ink); + font-size: clamp(14px, 1vw + 6px, 16px); + margin-bottom: 6px; +} + +.feature-item span { + color: #a6adb7; + font-size: clamp(13px, 0.8vw + 8px, 15px); +} + +/* The HTML hard-codes
in feature descriptions for desktop layout; + on narrow viewports those forced breaks produce ragged 2-word lines. + Collapse them so the text wraps naturally. */ +@media (max-width: 720px) { + .feature-item br { display: none; } +} + +.wave-lines { + position: absolute; + top: 152px; + width: 32%; + height: 120px; + opacity: 0.46; + background: + repeating-linear-gradient(108deg, transparent 0 24px, rgba(216, 168, 74, 0.52) 25px 27px, transparent 28px 50px); + mask-image: radial-gradient(ellipse, black 0 48%, transparent 72%); +} + +.wave-left { + left: 0; + transform: skewY(-7deg); +} + +.wave-right { + right: 0; + transform: skewY(7deg); +} + +/* Decorative diagonal lines crowd the headline on narrow viewports + (they're absolutely positioned at top: 152px; width: 32%) -- hide + them below the tablet breakpoint to keep the headline area clean. */ +@media (max-width: 720px) { + .wave-lines { display: none; } +} + +/* ───────────── Now-playing card ───────────── */ + +.now-playing { + display: grid; + grid-template-columns: 160px minmax(470px, 1.08fr) 300px 218px 210px 266px; + align-items: center; + gap: 0; + min-height: 224px; + padding: 23px 24px; + border-radius: 12px; +} + +.app:not(.is-import) .now-playing { + flex: 0 0 auto; + min-height: clamp(140px, 20vh, 198px); + padding-top: clamp(10px, 1.5vh, 18px); + padding-bottom: clamp(10px, 1.5vh, 18px); +} + +.app:not(.is-import) .np-art { + width: clamp(92px, 13vh, 132px); + height: clamp(92px, 14vh, 144px); +} + +.np-art { + position: relative; + width: 154px; + height: 168px; + border-radius: 9px; + overflow: hidden; + background: + linear-gradient(135deg, rgba(14, 22, 30, 0.95), rgba(5, 9, 13, 0.96)), + var(--panel-2); + flex-shrink: 0; +} +.np-art-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--ink-faint); + background: linear-gradient(135deg, #1a1a1a, #0e0e0e); +} +.np-art-img { + position: absolute; + inset: 6px; + width: 100%; + height: 100%; + width: calc(100% - 12px); + height: calc(100% - 12px); + object-fit: contain; + display: none; + border-radius: 6px; +} +.np-art-img.loaded { + display: block; +} +.np-art-play { + width: 50px; + height: 50px; + border-radius: 50%; + border: 0; + background: linear-gradient(180deg, var(--gold-bright), var(--gold)); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 12px 24px rgba(216, 168, 74, 0.2); + transition: transform var(--t-fast) var(--easing), background-color var(--t-base) var(--easing); +} +.np-art-play:hover { + background: linear-gradient(180deg, #ecc76e, #c78a30); +} +.np-art-play svg { + margin-left: 2px; +} + +.np-info { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; + padding: 0 28px 0 30px; + border-right: 1px solid var(--glass-line); +} +.np-title { + font-size: clamp(19px, 1.45vw, 24px); + font-weight: 500; + color: var(--ink); + line-height: 1.2; + white-space: normal; + overflow: hidden; + text-overflow: clip; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow-wrap: anywhere; + max-width: 100%; +} +.np-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.chip { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 5px 12px; + background: rgba(255, 255, 255, 0.055); + color: var(--ink); + border: var(--border-w) solid var(--line); + border-radius: var(--radius-xs); + font-size: 14px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.np-play-row { + display: grid; + grid-template-columns: auto minmax(250px, 1fr) auto; + align-items: center; + gap: 16px; + margin-top: 6px; + color: #c6cbd2; + font-size: 14px; +} + +.np-scrub { + height: 4px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.16); + position: relative; +} + +.np-scrub span { + position: absolute; + left: 0; + top: 50%; + width: 14px; + height: 14px; + transform: translateY(-50%); + border-radius: 50%; + background: var(--gold-bright); + box-shadow: 0 0 0 4px rgba(216, 168, 74, 0.18); +} + +.stem-energy, +.analysis-card { + min-height: 156px; + padding: 0 24px; + border-left: 1px solid var(--glass-line); +} + +.stem-energy { + grid-column: 3; +} + +.key-card { + grid-column: 4; +} + +.loudness-card { + grid-column: 5; +} + +.beat-card { + grid-column: 6; +} + +.loudness-card.hidden + .beat-card { + grid-column: 5 / 7; +} + +.stem-energy h3, +.analysis-card h3 { + margin: 0 0 13px; + color: #c9ced6; + font-size: 14px; + text-transform: uppercase; +} + +.stem-energy h3 span, +.analysis-card h3 span, +.panel-title span { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + margin-left: 4px; + border: 1px solid currentColor; + border-radius: 50%; + font-size: 10px; + opacity: 0.72; +} + +.energy-row { + display: grid; + grid-template-columns: 78px 1fr 40px; + align-items: center; + gap: 9px; + margin: 5px 0; + color: var(--stem-color); + font-size: 13px; +} + +.energy-row span { + font-weight: 700; +} + +.energy-row b { + height: 12px; + border-radius: 999px; + background: + linear-gradient(90deg, currentColor 0 var(--v, 0%), rgba(148, 163, 184, 0.13) var(--v, 0%) 100%); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); + transition: background 80ms linear; +} + +.energy-row em { + color: #c8cdd4; + font-style: normal; + font-weight: 700; +} + +.vocals { --stem-color: #e54249; color: var(--stem-color); } +.drums { --stem-color: #f06a14; color: var(--stem-color); } +.bass { --stem-color: #eab414; color: var(--stem-color); } +.guitar { --stem-color: #5bbf50; color: var(--stem-color); } +.piano { --stem-color: #7f45d8; color: var(--stem-color); } +.other { --stem-color: #3e8dcf; color: var(--stem-color); } +.original { --stem-color: #a8b0bd; color: var(--stem-color); } + +.analysis-card { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; +} + +.key-card { + align-items: center; + text-align: center; +} + +.beat-card { + align-items: center; + text-align: center; +} + +.analysis-card strong { + color: var(--gold-bright); + font-size: 26px; + line-height: 1.1; + font-weight: 500; +} + +.analysis-card small { + color: #b9bfc7; + font-size: 12px; +} + +.confidence-ring { + width: 66px; + height: 66px; + position: relative; + display: grid; + place-items: center; + border-radius: 50%; + background: + conic-gradient( + var(--gold) calc(var(--confidence-pct, 0) * 1%), + rgba(216, 168, 74, 0.16) 0 + ); + color: #e9edf2; + isolation: isolate; + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.confidence-ring::before { + content: ""; + position: absolute; + inset: 4px; + border-radius: 50%; + background: var(--panel); +} + +.confidence-ring::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + box-shadow: + inset 0 0 0 1px rgba(216, 168, 74, 0.12), + 0 0 14px rgba(216, 168, 74, 0.1); + pointer-events: none; +} + +.confidence-ring span { + position: relative; + z-index: 1; +} + +.loudness-card strong { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: baseline; + color: #f0f2f5; +} + +.beat-values { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + min-width: 150px; +} + +.beat-values strong { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: baseline; + color: var(--gold-bright); + text-align: left; +} + +.beat-values strong small { + display: block; + margin-top: 0; + color: #b9bfc7; +} + +.np-transport { + display: flex; + align-items: center; + gap: 26px; + color: #cfd4db; + font-size: 14px; + font-variant-numeric: tabular-nums; +} +.np-transport-row { + display: flex; + align-items: center; + gap: 22px; +} +.btn-transport { + width: 58px; + height: 58px; + background: rgba(255, 255, 255, 0.055); + color: var(--ink); + border: 1px solid var(--glass-line); + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: + background-color var(--t-base) var(--easing), + color var(--t-base) var(--easing), + border-color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} +.btn-transport:hover { + background: rgba(255, 255, 255, 0.09); + border-color: var(--line-strong); +} +.btn-transport:active { + transform: scale(0.94); +} +.btn-transport .pause-icon { + display: none; +} +.btn-transport.playing .pause-icon { + display: block; +} +.btn-transport.playing svg:not(.pause-icon) { + display: none; +} +.btn-transport.playing, +.btn-transport.stopped, +.btn-transport.loop.active { + background: linear-gradient(180deg, var(--gold-bright), var(--gold)); + color: white; + border-color: var(--gold-line); + box-shadow: 0 16px 34px rgba(216, 168, 74, 0.18); +} +.np-time { + font-size: 13px; + color: var(--ink-dim); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} diff --git a/static/css/variables.css b/static/css/variables.css new file mode 100644 index 00000000..22b9693b --- /dev/null +++ b/static/css/variables.css @@ -0,0 +1,51 @@ +:root { + color-scheme: dark; + --bg: #0a0a0a; + --bg-deep: #050505; + --panel: rgba(15, 24, 30, 0.78); + --panel-2: rgba(21, 31, 39, 0.82); + --panel-hover: rgba(31, 43, 52, 0.88); + --ink: #ececec; + --ink-dim: #9c9c9c; + --ink-faint: #6c6c6c; + --line: rgba(157, 170, 182, 0.14); + --line-strong: rgba(186, 197, 208, 0.24); + --gold: #d8a84a; + --gold-bright: #f1cf82; + --gold-soft: rgba(216, 168, 74, 0.16); + --gold-line: rgba(216, 168, 74, 0.28); + --glass: rgba(14, 23, 30, 0.78); + --glass-strong: rgba(24, 34, 43, 0.82); + --glass-line: rgba(157, 170, 182, 0.16); + --shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.3); + --accent: #6fb060; + --danger: #d65a4a; + --focus-ring: rgba(236, 236, 236, 0.55); + + --header-w: 320px; + --lane-h: 96px; + --radius: 8px; + --radius-sm: 6px; + --radius-xs: 4px; + --border-w: 1px; + + --font-mono: "JetBrains Mono", "SF Mono", "Menlo", "Consolas", monospace; + + --t-fast: 120ms; + --t-base: 180ms; + --easing: cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@media (prefers-reduced-motion: reduce) { + :root { + --t-fast: 0ms; + --t-base: 0ms; + } + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/static/css/waves.css b/static/css/waves.css new file mode 100644 index 00000000..ec9a5994 --- /dev/null +++ b/static/css/waves.css @@ -0,0 +1,998 @@ +/* ─── Post-import studio dashboard ─── */ + +.studio-dashboard { + display: flex; + flex-direction: column; + gap: 10px; +} + +.studio-top-grid { + display: grid; + grid-template-columns: minmax(760px, 1.42fr) minmax(560px, 1fr); + gap: 12px; +} + +.presence-panel, +.preview-panel, +.stems-panel, +.wave-editor { + border-radius: 12px; +} + +.panel-title, +.preview-head { + color: #d4d8de; + font-size: 15px; + font-weight: 700; + text-transform: uppercase; +} + +.presence-panel { + min-height: 292px; + padding: 17px 20px 16px; +} + +.presence-ruler { + display: grid; + grid-template-columns: 136px repeat(8, 1fr); + margin: 13px 0 5px; + color: #bec4cb; + font-size: 16px; + font-variant-numeric: tabular-nums; +} + +.presence-grid { + display: grid; + grid-template-columns: 136px 1fr; + gap: 12px; +} + +.presence-labels { + display: grid; + grid-template-rows: repeat(6, 33px); + align-items: center; + color: #cbd0d7; + font-weight: 700; +} + +.presence-labels span { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + font-size: 16px; +} + +.presence-icon { + width: 26px; + height: 26px; + flex: 0 0 auto; + filter: drop-shadow(0 0 8px color-mix(in srgb, currentColor 28%, transparent)); +} + +.presence-bars { + position: relative; + display: grid; + grid-template-rows: repeat(6, 33px); + border: 1px solid rgba(148, 163, 184, 0.12); + background: + repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.07) 0 1px, transparent 1px 6.25%), + rgba(2, 9, 14, 0.45); +} + +/* DAW-style playhead overlay -- thin vertical line that rides the + top edge of the bars container and tracks the current play time. + transform: translateX(-50%) so the line is centered on the exact + percentage. */ +.presence-playhead { + position: absolute; + top: -2px; + bottom: -2px; + left: 0; + width: 1.5px; + background: var(--gold); + transform: translateX(-50%); + pointer-events: none; + z-index: 4; + box-shadow: 0 0 6px color-mix(in srgb, var(--gold) 70%, transparent); +} + +.presence-playhead.hidden { + display: none; +} + +.presence-bars i { + position: relative; + display: block; + overflow: hidden; + border-bottom: 1px solid rgba(148, 163, 184, 0.09); + background: rgba(4, 13, 19, 0.34); + color: var(--stem-color); + --cell-hi: color-mix(in srgb, currentColor 78%, transparent); + --cell-md: color-mix(in srgb, currentColor 52%, transparent); + --cell-lo: color-mix(in srgb, currentColor 28%, transparent); + --cells: + repeating-linear-gradient(90deg, + var(--cell-lo) 0 7px, + transparent 7px 9px, + var(--cell-md) 9px 16px, + transparent 16px 18px, + var(--cell-hi) 18px 27px, + transparent 27px 31px, + var(--cell-md) 31px 36px, + transparent 36px 42px, + var(--cell-lo) 42px 49px, + transparent 49px 54px); + --presence-mask: linear-gradient(90deg, black 0 100%); +} + +.presence-bars i::before, +.presence-bars i::after { + content: ""; + position: absolute; + left: 16px; + right: 10px; + top: 6px; + bottom: 6px; + border-radius: 2px; + background: var(--cells); + -webkit-mask-image: var(--presence-mask); + mask-image: var(--presence-mask); + filter: drop-shadow(0 0 7px color-mix(in srgb, currentColor 22%, transparent)); +} + +.presence-bars i::after { + top: 9px; + bottom: 9px; + opacity: 0.34; + transform: translateX(5px); + background: + repeating-linear-gradient(90deg, + transparent 0 8px, + var(--cell-lo) 8px 13px, + transparent 13px 18px, + var(--cell-md) 18px 22px, + transparent 22px 29px); +} + +.presence-bars i:nth-child(2) { + --cell-hi: color-mix(in srgb, currentColor 82%, transparent); + --cell-md: color-mix(in srgb, currentColor 58%, transparent); + --cell-lo: color-mix(in srgb, currentColor 34%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 72%, transparent 72% 73.5%, black 73.5% 100%); +} + +.presence-bars i:nth-child(3) { + --cell-hi: color-mix(in srgb, currentColor 72%, transparent); + --cell-md: color-mix(in srgb, currentColor 48%, transparent); + --cell-lo: color-mix(in srgb, currentColor 24%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 15%, transparent 15% 31.8%, black 31.8% 63.5%, + transparent 63.5% 85.7%, black 85.7% 98%, transparent 98%); +} + +.presence-bars i:nth-child(4) { + --cell-hi: color-mix(in srgb, currentColor 70%, transparent); + --cell-md: color-mix(in srgb, currentColor 46%, transparent); + --cell-lo: color-mix(in srgb, currentColor 24%, transparent); + --presence-mask: + linear-gradient(90deg, + transparent 0 3.2%, black 3.2% 12.8%, transparent 12.8% 16.5%, + black 16.5% 91.5%, transparent 91.5% 94.7%, black 94.7% 99%, + transparent 99%); +} + +.presence-bars i:nth-child(5) { + --cell-hi: color-mix(in srgb, currentColor 76%, transparent); + --cell-md: color-mix(in srgb, currentColor 50%, transparent); + --cell-lo: color-mix(in srgb, currentColor 26%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 4.5%, transparent 4.5% 15.8%, black 15.8% 62.5%, + transparent 62.5% 70.5%, black 70.5% 96%, transparent 96%); +} + +.presence-bars i:nth-child(6) { + --cell-hi: color-mix(in srgb, currentColor 68%, transparent); + --cell-md: color-mix(in srgb, currentColor 45%, transparent); + --cell-lo: color-mix(in srgb, currentColor 23%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 12%, transparent 12% 23.5%, black 23.5% 52.2%, + transparent 52.2% 60.8%, black 60.8% 97%, transparent 97%); +} + +.presence-bars i:first-child { + --cell-hi: color-mix(in srgb, currentColor 74%, transparent); + --cell-md: color-mix(in srgb, currentColor 50%, transparent); + --cell-lo: color-mix(in srgb, currentColor 25%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 15.2%, transparent 15.2% 19%, black 19% 42.7%, + transparent 42.7% 50.3%, black 50.3% 60.6%, transparent 60.6% 65.8%, + black 65.8% 88.6%, transparent 88.6% 93.7%, black 93.7% 97.6%, + transparent 97.6%); +} + +.preview-panel { + min-height: 292px; + padding: 14px 18px 16px; +} + +.preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--glass-line); +} + +.preview-head div { + display: flex; + gap: 12px; +} + +.preview-head button, +.footer-btn { + min-height: 36px; + padding: 0 16px; + border: 1px solid var(--glass-line); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.04); + color: #d8dde3; + font: inherit; +} + +.preview-head button { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 14px; + font-weight: 700; +} + +.preview-mixer-body { + display: grid; + grid-template-columns: minmax(0, 1fr) 94px; + gap: 22px; + align-items: stretch; + padding-top: 14px; +} + +.master-strip { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto 128px; + gap: 10px 8px; + justify-items: center; + color: #d1d6dd; +} + +.master-strip .master-fader-track { + height: 128px; +} + +.master-strip > span { + grid-column: 1 / -1; +} + +.master-scale { + display: flex; + flex-direction: column; + justify-content: space-between; + color: #a8b0ba; + font-size: 12px; +} + +.studio-wave-grid { + display: grid; + grid-template-columns: 424px minmax(0, 1fr); + gap: 10px; +} + +.stems-panel { + min-height: 326px; + padding: 16px 18px; +} + +.stem-list { + display: flex; + flex-direction: column; + gap: 1px; + /* Aligns the first row with the first waveform lane in the wave-editor + panel next door. The wave editor stacks (zoom-toolbar) + (ruler) + above its first lane; the stems panel only has the "Stems" title. + This top padding fills the difference so each Vocals/Drums/etc. + label sits exactly opposite its corresponding waveform. */ + margin-top: 46px; +} + +.stem-list span { + /* Match the multitrack lane height (48px set in player.js wireUpAudio + options.height, plus 1px border between lanes). Combined with the + 1px gap above, each row strides 49px -- identical to wave lanes. */ + height: 48px; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 34px 34px 36px 30px; + align-items: center; + gap: 10px; + color: #d5dae0; + font-size: 17px; +} + +.stem-list em { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + color: #d5dae0; + font-style: normal; + font-weight: 700; + white-space: nowrap; +} + +.stem-icon { + width: 27px; + height: 27px; + flex: 0 0 auto; + color: var(--stem-color); + filter: drop-shadow(0 0 8px color-mix(in srgb, currentColor 26%, transparent)); +} + +.drag-handle { + width: 14px; + height: 22px; + opacity: 0.56; + background: + radial-gradient(circle, #c9ced6 1.4px, transparent 1.7px) 0 0 / 7px 7px; +} + +.stem-list b { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: #d8dde4; + font-size: 14px; + cursor: pointer; + user-select: none; + transition: + background-color var(--t-base) var(--easing), + color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.stem-list b:hover { + background: rgba(255, 255, 255, 0.1); +} + +.stem-list b:active { + transform: scale(0.92); +} + +.stem-list b:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 1px; +} + +.stem-list .stem-mute.active { + background: var(--danger); + color: white; +} + +.stem-list .stem-solo.active { + background: var(--gold); + color: #17130c; +} + +/* Mute the row visually when its stem is muted (matches lane-header). */ +.stem-list span.muted { + opacity: 0.45; +} + +.stem-list button { + width: 34px; + height: 34px; + border: 1px solid var(--glass-line); + border-radius: 7px; + background: + radial-gradient(circle at 50% 52%, transparent 0 6px, currentColor 6px 8px, transparent 8px), + linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.025)); + color: #cfd5dc; + cursor: pointer; + transition: + border-color var(--t-base) var(--easing), + color var(--t-base) var(--easing), + background-color var(--t-base) var(--easing); +} + +.stem-list button:hover { + border-color: var(--gold); + color: var(--gold); +} + +.stem-list .stem-monitor.active { + color: var(--gold); + border-color: var(--gold); + background: + radial-gradient(circle at 50% 52%, transparent 0 6px, currentColor 6px 8px, transparent 8px), + color-mix(in srgb, var(--gold) 18%, transparent); +} + +.mini-meter { + position: relative; + width: 20px; + height: 34px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: + linear-gradient(to top, + rgba(48, 197, 87, 0.14) 0 55%, + rgba(225, 178, 46, 0.14) 55% 80%, + rgba(214, 64, 64, 0.14) 80% 100%), + rgba(8, 14, 20, 0.85); + overflow: hidden; + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45); +} + +.mini-meter::before { + content: ""; + position: absolute; + inset: 1px; + background: linear-gradient(to top, + #30c557 0%, + #30c557 50%, + #e1b22e 60%, + #e1b22e 78%, + #d64040 88%, + #d64040 100%); + transform: scaleY(var(--vu-scale, 0)); + transform-origin: bottom; + /* Short transition so the meter still reads as fluid rather than + stepwise, but quick enough that drum-hit transients aren't washed + out. The peak-hold logic in audio.js does the real "VU feel". */ + transition: transform 30ms linear; + pointer-events: none; +} + +.mini-meter::after { + content: ""; + position: absolute; + left: 1px; + right: 1px; + height: 2px; + bottom: calc(var(--vu-peak-pct, 0) * 1%); + background: rgba(255, 255, 255, 0.78); + opacity: var(--vu-peak-opacity, 0); + transition: bottom 80ms linear, opacity 180ms linear; + pointer-events: none; +} + +.wave-editor { + min-width: 0; + min-height: 326px; + padding: 12px 14px 14px; +} + +.wave-toolbar { + display: flex; + justify-content: flex-end; + align-items: center; + margin-bottom: 6px; +} + +/* Shared scroll viewport for the ruler + waves. Putting both in the + same scroll container with a single inner canvas guarantees the + ruler-time element and the loop-region overlay share one coordinate + system, so click positions on the ruler line up perfectly with the + visual loop bars. The canvas also widens with --zoom so a single CSS + variable drives both visual layout and multitrack pxPerSec. */ +.wave-scroll { + overflow-x: auto; + overflow-y: hidden; + border: 1px solid rgba(148, 163, 184, 0.1); + background: rgba(3, 10, 15, 0.52); + scrollbar-width: thin; + scrollbar-color: rgba(216, 168, 74, 0.65) rgba(148, 163, 184, 0.12); + scroll-behavior: smooth; +} + +.wave-scroll::-webkit-scrollbar { + height: 10px; +} + +.wave-scroll::-webkit-scrollbar-track { + background: rgba(148, 163, 184, 0.1); + border-radius: 999px; +} + +.wave-scroll::-webkit-scrollbar-thumb { + background: linear-gradient(90deg, rgba(216, 168, 74, 0.55), rgba(245, 203, 110, 0.9)); + border: 2px solid rgba(3, 10, 15, 0.72); + border-radius: 999px; +} + +.wave-canvas { + position: relative; + width: calc(100% * var(--zoom, 1)); + min-width: 100%; +} + +.lanes-ruler { + height: 30px; +} + +.lanes-ruler-time { + position: relative; + height: 100%; + cursor: crosshair; + touch-action: none; + user-select: none; +} + +.tick { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px solid rgba(148, 163, 184, 0.2); + padding-left: 4px; + display: flex; + align-items: flex-start; + pointer-events: none; +} + +.tick-label { + color: #b8bec7; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.playhead-marker { + position: absolute; + top: 0; + left: 0; + transform: translateX(-50%); + pointer-events: none; + z-index: 8; +} + +.app:not(.is-import) .playhead-marker::after { + content: ""; + position: absolute; + left: 50%; + top: 8px; + width: 1.5px; + height: calc(var(--wave-playhead-h, 480px) + 26px); + transform: translateX(-50%); + background: #e54e4e; + box-shadow: 0 0 7px rgba(229, 78, 78, 0.34); + pointer-events: none; +} + +.zoom-tools { + display: flex; + align-items: center; + gap: 10px; + color: #c9ced6; + font-size: 18px; +} + +.zoom-slider { + width: 116px; + height: 18px; + accent-color: var(--gold); + background: transparent; + cursor: pointer; +} + +.zoom-slider::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: linear-gradient(90deg, var(--gold) calc(var(--zoom-frac, 0) * 100%), rgba(148, 163, 184, 0.2) calc(var(--zoom-frac, 0) * 100%)); +} + +.zoom-slider::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + margin-top: -5px; + border-radius: 50%; + background: #f3d37c; + border: 2px solid rgba(255, 255, 255, 0.78); + box-shadow: 0 0 10px rgba(216, 168, 74, 0.28); +} + +.zoom-slider::-moz-range-track { + height: 4px; + border-radius: 999px; + background: linear-gradient(90deg, var(--gold) calc(var(--zoom-frac, 0) * 100%), rgba(148, 163, 184, 0.2) calc(var(--zoom-frac, 0) * 100%)); +} + +.zoom-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: #f3d37c; + border: 2px solid rgba(255, 255, 255, 0.78); + box-shadow: 0 0 10px rgba(216, 168, 74, 0.28); +} + +.zoom-tools button { + min-height: 32px; + min-width: 32px; + padding: 0 12px; + border: 1px solid var(--glass-line); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: #d8dde3; + font: inherit; + cursor: pointer; + transition: background-color var(--t-base) var(--easing); +} + +.zoom-tools button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.zoom-tools button:disabled { + opacity: 0.4; + cursor: default; +} + +.zoom-tools #zoom-out, +.zoom-tools #zoom-in { + font-size: 22px; + padding: 0; + width: 32px; +} + +.zoom-tools .kebab { + width: 32px; + padding: 0; + font-size: 22px; +} + +.waves-column { + min-width: 0; + position: relative; + overflow: hidden; + min-height: 274px; + --wave-gutter: 0px; +} + +.waves-grid { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; +} + +.waves-grid .grid-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(148, 163, 184, 0.14); +} + +#multitrack-container { + width: 100%; + position: relative; + z-index: 2; +} + +.stem-waveform-layer { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + display: flex; + flex-direction: column; +} + +.stem-waveform-row { + /* Fixed 48px height matches the multitrack lane height + (set in player.js wireUpAudio options.height: 48) and the + stem-list row height in the Stems sidebar. Without this the + overlay used flex distribution across the whole waves-column, + producing rows ~0.8 px taller than the stem-list rows -- the + drift accumulated across 6 rows and visibly misaligned the + stem labels with their waveforms. */ + flex: 0 0 48px; + height: 48px; + position: relative; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); +} + +.stem-waveform-row:last-child { + border-bottom: 0; +} + +.stem-waveform-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.stem-waveform-svg path { + fill: var(--stem-color); + opacity: 0.78; + filter: drop-shadow(0 0 7px color-mix(in srgb, var(--stem-color) 30%, transparent)); +} + +#multitrack-container > div { + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + background: transparent !important; +} + +#multitrack-container > div:last-child { + border-bottom: 0; +} + +#multitrack-container [part="timeline-wrapper"], +#multitrack-container [part^="timeline-"] { + display: none !important; +} + +.waves-column [style*="z-index: 10"] { + transform: translateX(-50%); +} + +.loop-region { + position: absolute; + top: 0; + bottom: 0; + background: rgba(216, 168, 74, 0.1); + border-left: 2px solid var(--gold); + border-right: 2px solid var(--gold); + box-shadow: + inset 1px 0 0 rgba(255, 255, 255, 0.18), + inset -1px 0 0 rgba(255, 255, 255, 0.18); + pointer-events: none; + z-index: 3; +} + +.lane-placeholder { + height: 48px; + position: relative; +} + +.lane-placeholder::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 2px; + background: var(--lane-color, var(--line)); + opacity: 0.55; +} + +.transport-footer { + min-height: 86px; + display: grid; + grid-template-columns: minmax(0, 1fr); + justify-items: center; + align-items: center; + gap: 24px; + padding: 6px 4px 0; +} + +.transport-footer .np-transport { + justify-self: center; +} + +.footer-btn { + min-height: 66px; + font-size: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 18px; +} + +/* ─── Screenshot-aligned DAW layout ─── */ + +.app:not(.is-import) .studio-dashboard { + flex: 1 1 auto; + display: grid; + grid-template-columns: + minmax( + clamp(340px, calc((var(--visible-track-count, 6) * 70px) + 112px), 650px), + clamp(360px, calc((var(--visible-track-count, 6) * 78px) + 128px), 720px) + ) + minmax(820px, 1fr); + grid-template-rows: minmax(0, 1fr) minmax(74px, 86px); + gap: 10px; + min-height: 0; + overflow: hidden; +} + +.app:not(.is-import) .studio-top-grid, +.app:not(.is-import) .studio-wave-grid { + display: contents; +} + +.app:not(.is-import) .presence-panel { + display: none; +} + +.app:not(.is-import) .preview-panel { + grid-column: 1; + grid-row: 1; + min-height: 0; + padding: 10px 8px 12px; +} + +.app:not(.is-import) .wave-editor { + grid-column: 2; + grid-row: 1; + min-height: 0; + padding: 10px 12px; + overflow: hidden; +} + +.app:not(.is-import) .stems-panel { + grid-column: 2; + grid-row: 1; + align-self: stretch; + width: 48px; + margin: 68px 0 10px 12px; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + z-index: 4; + pointer-events: none; +} + +.app:not(.is-import) .stems-panel .panel-title { + display: none; +} + +.app:not(.is-import) .stem-list { + height: 100%; + margin: 0; + gap: 0; + display: flex; + flex-direction: column; +} + +.app:not(.is-import) .stem-list span { + flex: 1 1 0; + min-height: 0; + display: grid; + grid-template-columns: 1fr; + place-items: center; + gap: 0; + color: var(--stem-color); +} + +.app:not(.is-import) .stem-list span.hidden { + display: none; +} + +.app:not(.is-import) .stem-list .drag-handle, +.app:not(.is-import) .stem-list b, +.app:not(.is-import) .stem-list button, +.app:not(.is-import) .stem-list .mini-meter { + display: none; +} + +.app:not(.is-import) .stem-list em { + display: grid; + place-items: center; + font-size: 0; +} + +.app:not(.is-import) .stem-icon { + width: 24px; + height: 24px; +} + +.app:not(.is-import) .wave-toolbar { + height: 30px; + margin: 0 0 2px; +} + +.app:not(.is-import) .wave-scroll { + height: calc(100% - 32px); + overflow-x: auto; + overflow-y: hidden; + border-radius: 8px; +} + +.app:not(.is-import) .wave-canvas { + height: 100%; +} + +.app:not(.is-import) .lanes-ruler { + height: 34px; + margin-left: 48px; +} + +.app:not(.is-import) .waves-column { + min-height: calc(100% - 34px); + height: calc(100% - 34px); + padding-left: 48px; + --wave-gutter: 48px; +} + +.app:not(.is-import) .waves-grid { + left: 48px; +} + +.app:not(.is-import) #multitrack-container { + position: absolute; + inset: 0 0 0 48px; + opacity: 0; + pointer-events: none; +} + +.app:not(.is-import) .stem-waveform-layer { + left: 48px; + right: 0; + top: 0; + bottom: 0; +} + +.app:not(.is-import) .stem-waveform-row { + flex: 1 1 0; + min-height: 0; + height: auto; + border-bottom: 1px solid rgba(148, 163, 184, 0.11); +} + +.app:not(.is-import) .stem-waveform-svg { + inset: 0; + height: 100%; +} + +.app:not(.is-import) .stem-waveform-svg path { + opacity: 0.92; + filter: none; +} + +.app:not(.is-import) .transport-footer { + grid-column: 1 / -1; + grid-row: 2; + min-height: 0; + display: grid; + grid-template-columns: minmax(120px, 1fr) auto minmax(120px, 1fr); + align-items: center; + justify-items: stretch; + gap: 22px; + padding: 14px 16px; + border: 1px solid var(--glass-line); + border-radius: 10px; + background: + radial-gradient(circle at 50% 0, rgba(216, 168, 74, 0.05), transparent 28rem), + rgba(11, 22, 30, 0.68); +} + +.app:not(.is-import) .footer-btn { + min-width: 138px; + min-height: 54px; + padding: 0 20px; + font-size: 14px; +} + +.app:not(.is-import) .transport-footer .np-transport { + grid-column: 2; + justify-self: center; +} + +.footer-actions { + display: flex; + grid-column: 3; + justify-self: end; + gap: 18px; +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 00000000..e9ee16f2 --- /dev/null +++ b/static/index.html @@ -0,0 +1,405 @@ + + + + + + StemDeck + + + + + + + + + + + + + + +
+ +
+
+
+

StemDeck

+ ALPHA +
+ AI stem separation for YouTube +
+ +
+
+ + +
+
+ Stems to extract + + + + + + +
+
+
+ +
+ + + +
+

Extract any song from YouTube

+

Separate into stems using advanced AI technology

+
+ +
+ + Your data is encrypted and never stored +
+ +
+
+ +
High qualityPowered by advanced
AI separation
+
+
+ +
Fast processingGet your stems in just
a few minutes
+
+
+ +
Secure & privateYour data is encrypted
and never shared
+
+
+
+ + +
+
+ + +
+ +
+
+
+ — BPM + — — + — Stems +
+
+ 00:00 + + 00:00 / 00:00 +
+
+ +
+

Stem Energy i

+
Original0%
+
Vocals0%
+
Drums0%
+
Bass0%
+
Guitar0%
+
Piano0%
+
Others0%
+
+ +
+

Key & Scale

+ — + + + +
+ + + +
+

Beat Overview

+
+ — BPM + 4/4 Time Sig. +
+
+
+ + + + + + + +
+
+ + +
+
+
+
+
+
+ +
+ + +
+
+
+ + + + + + +
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ + +
+ + +
+ + + + diff --git a/static/js/audio.js b/static/js/audio.js new file mode 100644 index 00000000..47e575b3 --- /dev/null +++ b/static/js/audio.js @@ -0,0 +1,144 @@ +import { TRACK_NAMES } from "./constants.js"; +import { + multitrack, mixerEl, audioContext, trackAnalysers, + vuRafId, trackIndex, + setAudioContext, setVuRafId, +} from "./state.js"; + +export function attachAnalysers() { + if (!multitrack) return; + if (trackAnalysers.length) return; + const wsArr = multitrack.wavesurfers || multitrack._wavesurfers; + const audios = multitrack.audios; + if (!wsArr?.length || !audios?.length) return; + + const ctx = multitrack.audioContext; + if (!ctx) return; + if (ctx.state === "suspended") ctx.resume().catch(() => {}); + setAudioContext(ctx); + + for (const stemName of TRACK_NAMES) { + const idx = trackIndex[stemName]; + if (idx === undefined) continue; + const audioEl = audios[idx]; + if (!audioEl) continue; + + const isHtmlMedia = audioEl instanceof HTMLMediaElement; + const isWebAudio = typeof audioEl.getGainNode === "function"; + if (!isHtmlMedia && !isWebAudio) continue; + if (isHtmlMedia && !audioEl.src) continue; + + let analyser; + try { + analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + if (isWebAudio) { + // wavesurfer's audio wrapper exposes its internal gain node; + // analyser taps the post-gain signal. + audioEl.getGainNode().connect(analyser); + } else { + // Bare HTMLAudioElement (Safari path -- captureStream isn't + // supported on