Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d64bfc4
feat(ui): redesign frontend to flat dark DAW aesthetic
thcp May 16, 2026
6159b2e
fix(ui): topbar and composer cleanup, notification panel, layout tweaks
thcp May 16, 2026
ebbe87b
fix(ui): expand composer pill to full topbar width
thcp May 16, 2026
c94b25b
fix(ui): restore waveform artstyle and wire All stem toggle
thcp May 16, 2026
4571b7b
fix(ui): remove waves.css wave-editor padding/radius in flat layout
thcp May 16, 2026
ede0385
fix(ui): restore SVG waveform layer — fix height chain and remove 48p…
thcp May 16, 2026
7aa81bf
fix(ui): show WaveSurfer native bar rendering, hide SVG waveform layer
thcp May 16, 2026
39e7751
fix(ui): align mixer rows with waveform, fix transport buttons, remov…
thcp May 16, 2026
9efa69a
fix(ui): show loop region as full-height waveform overlay
thcp May 16, 2026
52bb727
feat(ui): remove all zoom capability
thcp May 16, 2026
b5e5477
fix(ui): audio playback, sidebar navigation, and catalog deduplication
thcp May 17, 2026
7b5758a
feat(library): subfolders, folder drag-reorder, sidebar fixes, and tr…
thcp May 17, 2026
4d4c573
feat(ui): stem presence cards panel (Part A)
thcp May 17, 2026
a0ce970
feat(ui): redesign track info panel to 2-row card layout
thcp May 17, 2026
f74f935
feat(analysis): add dynamic range and tempo stability metrics
thcp May 17, 2026
488f6e5
fix(ui): gray out stem presence cards with 0% value
thcp May 17, 2026
162f7cb
feat(ui): add favorites heart, Extracted/Source/Quality to track card
thcp May 17, 2026
08de335
fix(ui): All button only active when all stems selected
thcp May 17, 2026
477775a
fix(ui): gray out non-extracted stems in mixer and waveform
thcp May 17, 2026
7a9d795
fix(ui): align waveform rows with mixer by using fixed TRACK_NAMES po…
thcp May 17, 2026
33e8754
feat(library): redesign sidebar with Recent, Stem Collections, Tags, …
thcp May 17, 2026
73ae289
feat(sections): interactive sections bar with drag, resize, rename, a…
thcp May 17, 2026
4950430
fix(sections): move Add button into Sections label, add Mixer label t…
thcp May 17, 2026
e954e27
fix(sections): persist sections correctly when switching tracks
thcp May 17, 2026
c9b45db
feat(sections): saving indicator — spinner while PATCH in-flight, Sav…
thcp May 17, 2026
1bc2eec
fix(sections): persist sections across backend restarts and fix save …
thcp May 17, 2026
6e8a736
feat(library): extract YouTube tags and show in library sidebar
thcp May 17, 2026
c100658
fix(ui): align mixer rows with waveform by moving original to bottom
thcp May 17, 2026
1abc03f
fix(ui): move original track to top when present, not bottom
thcp May 17, 2026
12d9b6e
fix(ui): show all 6 stem rows when original track is present
thcp May 17, 2026
b1a9335
fix(ui): guarantee waveform loading overlay is visible for at least 9…
thcp May 18, 2026
ff361eb
feat(ui): show waveform loading overlay during entire pipeline + load
thcp May 18, 2026
d6995b0
feat(transport): add time display and export buttons to footer bar
thcp May 18, 2026
8305ff7
feat(transport): redesign footer bar with track info, scrub, speed dr…
thcp May 18, 2026
d6ee3bc
fix(transport): center-align footer buttons and add visible stop/loop…
thcp May 18, 2026
63f74c7
feat(footer): move track info panel to footer and add placeholder wav…
thcp May 18, 2026
fb8d8b2
feat(ui): topbar sizing, footer heart placement, waveform coverage fixes
thcp May 18, 2026
363f28b
fix(ui): fill mixer and waveform vertical space dynamically
thcp May 18, 2026
72297e3
fix(export): make WAV/MP3 export buttons work
thcp May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from pydantic import BaseModel, field_validator

from app.core.config import (
JOB_ID_RE,
Expand Down Expand Up @@ -271,6 +271,82 @@ def cancel_job(job_id: str) -> dict:
return job.to_state()


_SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")


class SectionItem(BaseModel):
id: str
name: str
start: float
end: float
color: str

@field_validator("id")
@classmethod
def _check_id(cls, v: str) -> str:
if not _SECTION_ID_RE.match(v):
raise ValueError("invalid section id")
return v

@field_validator("name")
@classmethod
def _check_name(cls, v: str) -> str:
return v.strip()[:64] or "Section"

@field_validator("color")
@classmethod
def _check_color(cls, v: str) -> str:
if not _COLOR_RE.match(v):
raise ValueError("invalid color")
return v

@field_validator("start", "end")
@classmethod
def _check_time(cls, v: float) -> float:
if not (0 <= v < 86400):
raise ValueError("time out of range")
return round(v, 3)


class SectionsBody(BaseModel):
sections: list[SectionItem]


@router.patch("/{job_id}/sections")
def update_sections(job_id: str, body: SectionsBody) -> dict:
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")

validated = [s.model_dump() for s in body.sections]
job.sections = validated

job_dir = (JOBS_DIR / job_id).resolve()
if not job_dir.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="job not found")
meta_path = job_dir / "metadata.json"

meta: dict = {}
if meta_path.is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
meta["sections"] = validated
try:
meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
logger.exception("failed to write sections for %s: %s", job_id, exc)
raise HTTPException(status_code=500, detail="failed to save sections") from exc

registry_persist(JOBS_DIR)

return {"job_id": job_id, "sections": validated}


@router.delete("/{job_id}")
def delete_job(job_id: str) -> dict[str, str]:
if not JOB_ID_RE.match(job_id):
Expand Down
46 changes: 44 additions & 2 deletions app/api/stems.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from __future__ import annotations

import asyncio

from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, StreamingResponse

from app.core.config import JOB_ID_RE, JOBS_DIR, STEM_NAMES
from app.core.config import JOB_ID_RE, JOBS_DIR, STEM_NAMES, ffmpeg_executable
from app.core.registry import get as registry_get

router = APIRouter(tags=["stems"])
Expand All @@ -30,3 +32,43 @@ def get_stem(job_id: str, name: str) -> FileResponse:
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")


@router.get("/jobs/{job_id}/stems/{name}.mp3")
async def get_stem_mp3(job_id: str, name: str) -> StreamingResponse:
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")
wav_path = (JOBS_DIR / job_id / "stems" / f"{name}.wav").resolve()
if not wav_path.is_file() or not wav_path.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="stem not found")

async def _stream():
proc = await asyncio.create_subprocess_exec(
ffmpeg_executable(),
"-nostdin", "-loglevel", "error",
"-i", str(wav_path),
"-q:a", "2", # VBR ~190 kbps
"-f", "mp3", "pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
while True:
chunk = await proc.stdout.read(65536)
if not chunk:
break
yield chunk
finally:
proc.stdout.close()
await proc.wait()

return StreamingResponse(
_stream(),
media_type="audio/mpeg",
headers={"Content-Disposition": f'attachment; filename="{name}.mp3"'},
)
11 changes: 11 additions & 0 deletions app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class Job:
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)
dynamic_range: float | None = None # peak_db - integrated LUFS (dB)
tempo_stability: int | None = None # 0-100, beat interval consistency
stem_presence: dict[str, int] | None = None # per-stem RMS 0-100
sections: list[dict] | None = None # [{id, name, start, end, color}]
tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8
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
Expand Down Expand Up @@ -58,11 +63,17 @@ def to_state(self) -> dict[str, Any]:
"key_confidence": self.key_confidence,
"lufs": self.lufs,
"peak_db": self.peak_db,
"dynamic_range": self.dynamic_range,
"tempo_stability": self.tempo_stability,
"stem_presence": self.stem_presence,
"sections": self.sections,
"tags": self.tags,
"stems": self.stems,
"selected_stems": self.selected_stems,
"mix_url": self.mix_url,
"source_url": self.source_url,
"error": self.error,
"created_at": self.created_at,
}

def to_record(self) -> dict[str, Any]:
Expand Down
5 changes: 5 additions & 0 deletions app/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ def _recover_done_job(job_dir: Path) -> Job | None:
key_confidence=meta.get("key_confidence"),
lufs=meta.get("lufs"),
peak_db=meta.get("peak_db"),
dynamic_range=meta.get("dynamic_range"),
tempo_stability=meta.get("tempo_stability"),
stem_presence=meta.get("stem_presence"),
sections=meta.get("sections"),
tags=meta.get("tags"),
)


Expand Down
52 changes: 51 additions & 1 deletion app/pipeline/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,38 @@ def _load_audio_ffmpeg(
return y, sr


def compute_stem_presence(stems_dir: Path, selected_stems: list[str]) -> dict[str, int]:
"""Load each extracted stem WAV, compute mean absolute amplitude, normalize
to 0-100. Only the stems that were selected (and therefore extracted) are
measured; the rest are omitted from the returned dict."""
import numpy as np

result: dict[str, int] = {}
rms_values: dict[str, float] = {}

for name in selected_stems:
wav_path = stems_dir / f"{name}.wav"
if not wav_path.is_file():
continue
loaded = _load_audio_ffmpeg(wav_path, sr=22050, duration=180.0)
if loaded is None:
continue
y, _ = loaded
rms_values[name] = float(np.sqrt(np.mean(y ** 2)))

if not rms_values:
return result

max_rms = max(rms_values.values())
if max_rms < 1e-9:
return {name: 0 for name in rms_values}

for name, rms in rms_values.items():
result[name] = max(0, min(100, round(rms / max_rms * 100)))

return result


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."""
Expand All @@ -241,7 +273,7 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]:
# 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)
tempo_arr, beat_frames = librosa.beat.beat_track(y=y_percussive, sr=sr)
try:
tempo = float(tempo_arr[0]) # type: ignore[index]
except (TypeError, IndexError):
Expand All @@ -263,6 +295,22 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]:
# it's good enough for a UI display and adds ~50 ms to analyze.
lufs, peak_db = _measure_loudness(y, sr)

dynamic_range: float | None = None
if lufs is not None and peak_db is not None:
dynamic_range = round(peak_db - lufs, 1)

# Beat interval coefficient of variation → stability 0-100.
# CV = std/mean of inter-beat intervals; CV=0 is perfectly metronomic.
tempo_stability: int | None = None
import numpy as np
beat_times = librosa.frames_to_time(beat_frames, sr=sr)
if len(beat_times) > 2:
intervals = np.diff(beat_times)
mean_iv = float(intervals.mean())
if mean_iv > 0:
cv = float(intervals.std() / mean_iv)
tempo_stability = max(0, min(100, round((1 - min(cv, 1)) * 100)))

_set(
job,
bpm=bpm,
Expand All @@ -271,6 +319,8 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]:
key_confidence=key_confidence,
lufs=lufs,
peak_db=peak_db,
dynamic_range=dynamic_range,
tempo_stability=tempo_stability,
progress=1.0,
stage="Analysis complete",
)
Expand Down
9 changes: 9 additions & 0 deletions app/pipeline/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,15 @@ def hook(d: dict) -> None:
thumbnail=info.get("thumbnail") or meta.get("thumbnail"),
)

raw_tags = [
t.strip().lower()
for t in (info.get("tags") or []) + (info.get("categories") or [])
if isinstance(t, str) and t.strip()
]
seen: set[str] = set()
deduped = [t for t in raw_tags if not (t in seen or seen.add(t))] # type: ignore[func-returns-value]
_set(job, tags=deduped[:8] or None)

candidates = sorted(job_dir.glob("source.*"))
if not candidates:
raise RuntimeError("yt-dlp finished but no source file was produced")
Expand Down
7 changes: 6 additions & 1 deletion app/pipeline/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from app.core.config import TIMEOUT_FFMPEG
from app.core.models import Job, JobCancelled
from app.core.registry import persist as persist_registry
from app.pipeline.analyze import analyze
from app.pipeline.analyze import analyze, compute_stem_presence
from app.pipeline.collect import cleanup_source, collect, make_original_track, make_selected_mix
from app.pipeline.download import _set, download
from app.pipeline.separate import separate
Expand Down Expand Up @@ -83,6 +83,7 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None:
stems_root = separate(job, source, job_dir)
found = collect(job, stems_root, job_dir)
stems_dir = job_dir / "stems"
job.stem_presence = compute_stem_presence(stems_dir, found)
# Source (100-300 MB or the local upload) is no longer needed after
# collect; delete it before the ffmpeg amix steps in case scratch space
# is tight.
Expand Down Expand Up @@ -129,6 +130,10 @@ def _write_metadata(job: Job, job_dir: Path) -> None:
"key_confidence": job.key_confidence,
"lufs": job.lufs,
"peak_db": job.peak_db,
"dynamic_range": job.dynamic_range,
"tempo_stability": job.tempo_stability,
"stem_presence": job.stem_presence,
"tags": job.tags,
}
try:
(job_dir / "metadata.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
Expand Down
Loading