39 Commits

Author SHA1 Message Date
Tha.Les bf561a6c81 Lead/backing vocal split, stems relocation fixes, eager model pre-download (#406)
* Add on-demand lead/backing vocal split, fix stems relocation bugs, and eager model pre-download (#275, #403)

Lead/backing vocal split:
- New on-demand POST /api/jobs/{id}/vocal-split endpoint, running UVR-MDX-NET
  Karaoke 2 (audio-separator) as a second pass over Demucs's vocals.wav
- Desktop and mobile UI toggle to request the split, auto-chained once the
  base separation finishes, for both foreground and background jobs
- Mixer shows Lead Vocals / Backing Vocals lanes in place of Vocals once split

Stems relocation fixes (#403):
- user-data.json (library metadata) now lives inside the jobs folder so it
  follows a Settings relocation instead of staying behind in Documents
- The relocation endpoint's settings persist step was silently swallowing
  write failures and reporting false success; it now reports persisted:
  false and the Settings UI shows a clear warning instead

Desktop setup wizard:
- Demucs, beat-this, and the karaoke model now download eagerly during
  first-boot setup instead of lazily on first use

Also:
- Credit audio-separator / Ultimate Vocal Remover in the README per its
  license's attribution requirement, plus a license audit in docs/models.md
- Add models/ to .gitignore

* ci: install build-essential so diffq (audio-separator's dependency) can compile

diffq has no prebuilt wheel for Python 3.11+ on Linux, its last release only
ever shipped cp310 wheels, so uv sync must compile it from source, which
needs gcc. Docker and the Linux desktop release build already install
build-essential for the same reason; the plain lint/test CI container never
needed it before audio-separator (#275) pulled diffq in.

* chore: pin Unraid template to 0.12.0

This PR ships as v0.12.0, per the user's decision given it introduces the
new lead/backing vocal split feature.

---------

Co-authored-by: Thales <>
2026-08-21 17:43:03 +01:00
Thales 7d0ef12302 Rework the notification centre's failure report: fix the Windows Explorer
bug, add Discord, full traceback, opt-in logs, and anonymization

Root cause of the Explorer bug: the pre-filled GitHub URL carried the full
diagnostic dump (up to 6000 chars) as a query param, and Windows opens it via
explorer.exe, which silently falls back to a plain File Explorer window past
roughly 2000 characters instead of erroring. buildReportUrl() now fills the
"Logs / screenshots" field directly with as much of the traceback/stderr
tail as fits (keeping the end, where the actual error is - no paste needed
for the common case), and only points at the clipboard for what doesn't fit.
buildReportText() always has the complete, untruncated version.

Also added:
- A second "Report on Discord" button next to "Report on GitHub".
- Full backend traceback capture (_quarantine_failed_job), not just a
  one-line exception repr - fixed a latent bug in the same change where the
  tail parser would have silently swallowed a second section into the first.
- An opt-in "Include recent logs" button pulling from the backend/
  application/setup log views already exposed by Settings -> Logs, scoped to
  a window around the failure's own timestamp.
- Anonymization (app/core/redact.py): strips the reporter's home directory,
  any YouTube/SoundCloud source URL (download.py logs every job's URL, not
  just the failing one - a raw log tail would otherwise leak everything
  imported in the fetched window), and any IPv4 address (the mobile UI talks
  to this backend over the LAN). Applied unconditionally in GET
  /api/logs/{view}, not just for the report flow, and to the per-job
  traceback/tail/exception before error.txt is ever written. title:/source:
  stay unredacted in that file on purpose - they're already excluded from
  the public API response, so redacting them there loses local diagnostic
  value for no privacy gain.

Closes #381, #384
2026-08-17 17:47:07 +01:00
Thales 0226907255 Surface unavailable/broken tracks in stem collections with one-click reimport
The backend now checks the stems folder on disk for every "done" job and
reports "unavailable" when it's missing, replacing the old client-side
heuristic that only reacted to a 404 on the single-job endpoint and missed
the case where the registry entry survived but the folder did not. Desktop
shows a yellow "click to reimport" warning wired to the existing
importFromUrl restore path; mobile gets the same detection and one-tap
reimport from scratch, since it had none before.

Closes #380
2026-08-17 17:46:29 +01:00
Tha.Les 2c3541d311 Report a failure from the notification centre (#372)
* feat(ui): report a failure from the notification centre

A failure used to live in a transient #error banner. Dismiss it, or reload,
and the evidence was gone -- which is the position #359 complained about,
where a reporter has nothing to paste and guesses at a cause instead. #343 is
the standing proof: its author blamed a GPU and sent the investigation the
wrong way. This session hit the same wall, a "demucs exited 1 (no stderr
captured)" that was really a missing ffmpeg on PATH.

Failures now land in the notification centre, survive a reload, and open a
dialog that can hand the whole thing to GitHub as a pre-filled bug report --
version, OS, install method, stage, device, model and the stderr tail already
in the form. The user adds what they were doing and ticks the two preflight
boxes, which GitHub cannot prefill and which are the point.

Covers import (foreground and background), playback, export and update
failures. A background import that failed used to say nothing whatsoever: no
banner, no queue UI, just a console warning and a library row identical to a
healthy one. Queue three tracks, lose one, never find out.

- Deliberately not wired into showError wholesale: it also carries benign
  validation ("Only MP3, WAV... are supported"), which must not file a bug.
- One failure, one card. The foreground SSE handler and the background queue
  reconciler can both notice the same dead job, and applyState can run its
  error branch on more than one frame, so records key on the job id.
- classify_failure()'s "unknown" sentinel is dropped rather than shown: as a
  card it read "Import failed - unknown", and as an issue title it grouped
  every unclassified failure under one meaningless heading.

Privacy: the report carries technical details only. Track title and source URL
are never included -- issues are public, and the user adds them if they help.
GET /api/jobs/{id}/failure enforces that server-side by parsing error.txt and
serving a whitelist, rather than trusting the client to filter the file.

That endpoint also closes a gap: the pipeline has written the quarantined
error.txt since #277 -- classified cause, device, model, timings, 40-line
stderr tail -- and nothing ever read it back, so the UI had only the one-line
error_detail. It is the difference between "demucs failed" and "CUDA out of
memory: tried to allocate 2.40 GiB".

The notification centre had no generic add-a-card path: one hardcoded release
card, and badge/empty-state toggled inline at its two call sites assuming
exactly one card. That is centralised in notifications.js now, with the
release card keeping its own per-version dismissal key.

Tests: tests/js/report-url.test.mjs pins the dropdown strings (an OS that does
not match an option exactly is dropped by GitHub without complaint), the URL
length ceiling, tail truncation keeping the end where the error is, and that
no title or source URL can appear. tests/e2e/report-failure.spec.mjs covers
the desktop path, where the link is intercepted and handed to open_url rather
than navigating -- a break there would do nothing in the shipped app while
working in every browser a developer tests in.

* fix(settings): registry pane stuck on "Loading…", and add the backend log view

Two Settings defects, both found by looking at the pane rather than the code.

**Registry never loaded.** loadRegistryView selected `.settings-registry-view`
unscoped, but the two log viewers reuse that class for its read-only-textarea
styling and sit earlier in the markup. The lookup therefore returned the
*application log* box: the registry JSON was written into a hidden textarea
while the registry pane kept its literal "Loading…" placeholder for ever, and
the application log showed registry JSON until it was refreshed. Scope the
lookup to the registry pane. Not web-only -- it never worked anywhere.

**backend.log had no viewer.** It was listed under Logs → Location and shipped
in the logs zip, but the only two views were application and setup, so the one
log that holds what killed a backend before its own logging was configured was
the one log you could not read in the app. It gets a "Backend log" tab beside
the other two, reading backend.log plus its two rotations.

The sub-tab wiring is already generic (loadLogTail(overlay, name)), so the tab
needed markup and a view entry, no new JS.

Tests: the backend view's window filtering and rotation ordering, plus one that
walks _LOG_FILES against _LOG_VIEWS and fails if a file the Settings pane
advertises has no view to read it in -- which is exactly how backend.log stayed
invisible.

* fix(ui): keep a failure recorded during startup from being overwritten

initNotifications assigned the stored list over whatever was already in
memory. Reading the store is async, so a failure recorded while that read was
in flight was dropped -- losing exactly the notification the user would then
go looking for. Merge by id instead, newest first.

Latent rather than observed: the current call order records nothing that
early. It is one line, and the alternative is a bug that only ever appears
when something else has already gone wrong.

* test(e2e): stop the update check reaching GitHub, and pin the shared badge

CI failed two notification tests that pass on any developer machine. The
update check hits api.github.com for real; when the published release is newer
than the version under test, an update card appears and lights the same badge
failure notifications use. The tests then saw a lit badge with no failures.
Locally it never happened, because a dev build reports a version containing
"dev" and the check skips those -- the tests were passing for the wrong reason.

Answer the update check from the test instead, which also takes an external
service out of the path of every run.

The behaviour CI caught is correct and now has a test of its own: with an
update pending, dismissing the last failure card leaves the badge lit and the
empty state hidden, because the update is still there. openStudio grows an
`updateAvailable` option that forces that state (stubbing the version too --
the check skips dev builds, so a release-looking version is required for the
card to appear at all).

---------

Co-authored-by: Thales <>
2026-08-16 21:11:43 +01:00
Tha.Les fea4fcf145 Count-in, and a transport footer rebuilt around the studio's column grid (#369)
* feat(playback): count-in before playback and exports, redesign transport footer

Count-in (#269): one bar of click count-in leads into playback and into
audio exports, independent of the running click track (a clean backing
track can still get a count-in). The lead-in math is defined once and
mirrored between metronome.js and click_render.py, pinned by parity
tests on both sides.

- Playback: audioEngine schedules stem playback on a future ctx-time
  start so the count-in clicks land in the silent gap before the song
  begins; the metronome schedules them through the same clock mapping
  the running click already uses.
- Export: stems are delayed via ffmpeg's adelay and the click WAV is
  rendered in output coordinates when a count-in is requested, so it
  isn't re-trimmed by the region -ss like a plain click.

Also rebuilds the transport footer around labelled control groups
(Transport, Position, Speed, Click Track) instead of a right-click
popover: playback speed collapses to three practice presets (0.25x /
0.5x / 1x), the click track gets an on/off toggle and a count-in
switch, and the track-info block collapses from four stacked detail
rows to one compact line.

* fix(ui): hide click-track panel by default before any track is loaded

The panel lost its default "hidden" class when it changed from a
right-click popover to always-inline (#269 follow-up) -- on a fresh
page load, before any track was ever picked, nothing forced it
hidden, so "Ready to import a track" showed a full set of live-
looking click controls for a track that didn't exist.

* polish(ui): footer wave time labels, orphan dividers, visible click-volume readout

- Time labels above the footer's mini waveform, matching the main ruler.
- Divider marks between control clusters in the footer's controls row,
  hidden via ResizeObserver when wrapping strands one at the end of a
  line with nothing after it to separate.
- Click volume percentage shown next to the slider again instead of
  screen-reader-only -- a level you can only learn by hovering isn't
  one you can reliably match between sessions.
- Count-in switched from a checkbox to a press-to-toggle button,
  matching the click on/off control beside it (both answer "is this on
  for the next play?", so they read as the same kind of control now).

* fix(playback): count-in never armed on the chunked audio engine

The chunked engine is the default playback path (engineMode() falls
back to "chunked" unless a debug localStorage flag forces
"fulldecode") -- but count-in support (play(leadIn), supportsCountIn,
a clamped getCurrentTime during the lead-in) was only ever added to
audioEngine.js, the full-decode path. Since _armCountIn() bails out
whenever eng.supportsCountIn is falsy, count-in silently never armed
for any track played through the engine essentially everyone actually
uses, and playback started immediately regardless of the toggle.

Mirrors the same fix in chunkedAudioEngine.js: play() accepts a
leadIn and schedules the first chunk that far in the future (falling
back to the existing 10ms/50ms margins when there is no count-in),
and getCurrentTime() clamps to the start offset during that gap
instead of reading negative.

Verified directly against the running engine clock (not just DOM
text, which rounds to whole seconds): the position holds at the start
offset for the full lead-in and then advances normally, pausing
mid-count-in stops cleanly with no phantom scheduled audio, and
replaying re-arms a fresh count-in.

* polish(ui): align the footer with the lane column, move track info into it

The footer's waveform strip ran the full width of the window while the lane
waveforms above it start after the 300px stems/mixer panel, so the same
position sat at two different x positions in the two strips and neither
ruler's ticks lined up with the other's.

The footer is now two columns on the studio's own grid. Everything
time-related -- the control clusters, the waveform, its ruler and the
detection note -- sits in the right column and starts exactly where the lane
waveforms start, running flush to the window edge like they do. The track
identity (art, title, meta, favourite, Export Mix) moves into the left column
under the mixer panel and shares its width and 14px padding, so titles, stem
names and the "Mixer" heading share one left edge down the page. That also
drops a whole row from the footer: 255px tall where the three stacked tiers
were 318px.

- The 300px is now --daw-col-w, read by the stems panel, the label cell above
  it and the footer, instead of being hardcoded in each.
- The waveform strip is full-bleed with top/bottom rules rather than a
  rounded inset panel: a side border would have offset the canvas by its own
  width, which is exactly the misalignment being fixed.
- Both rulers share tickStep(), so a time is labelled at the same x in each.
- The export menu opens up and to the right; right-aligned from the left
  column it would have hung over the sidebar.

Grid becomes a press-to-toggle button matching the click and count-in buttons
beside it -- click opens the editor and lights it, click again closes it. Its
lit state is synced inside toggleBeatGridEditor, the one place every open and
close runs through, so Done, Escape and losing the beat grid all leave the
button correct. The G shortcut is gone: the button says what it does now, and
a single letter bound to a modal editor is easy to hit by accident.

* polish(ui): close the footer waveform strip's open left edge

The strip carries only top and bottom rules -- side borders were dropped so
the canvas would land exactly on the lane waveforms' left edge -- which left
its left end open, the two rules stopping in mid-air.

Drawn as an outset box-shadow rather than a border-left: a border sits inside
the box and would push the canvas a pixel off the alignment it exists to
keep. The line falls on the same x as the stems panel's right border, so that
seam now runs unbroken from the top of the mixer to the bottom of the strip.

* fix(ui): ticking an export option no longer closes the export menu

Every interactive element in the export menu called stopPropagation so the
document-level dismiss handler would not fire, but the two option checkboxes
had no click handler at all -- so ticking one bubbled out and closed the menu
under the pointer.

That was survivable with one checkbox. This branch adds a second ("Add
count-in"), and wanting both is the normal case for practising to a click:
the first tick closed the menu, and the second needed it reopened.

Guard the panel itself rather than adding a third per-element stopPropagation
that the next option added would forget: a click inside a menu is not a click
away from it. Nothing depended on the bubble to close the menu -- the export
actions close it themselves through enterBusy() -> closePanel().

---------

Co-authored-by: Thales <>
2026-08-16 19:15:52 +01:00
Tha.Les 1e8610bbc5 feat(settings): choose where extracted stems are stored (#355)
Settings -> General gains a StemData location row: where extracted stems live, how much is there, and a native folder picker to change it. Changing it moves the existing library, since the registry lives in that folder and leaving it behind would strand it.

Desktop only -- Docker and Unraid get their storage from a mounted volume, and STEMDECK_JOBS_DIR still overrides everything.

Closes #354.
2026-08-12 11:25:06 +01:00
Tha.Les afe871ce81 feat: background import queue, playlist import, and queue management (#350)
Imports run through an explicit serial queue: queue several tracks, a playlist, or a folder of files and keep using StemDeck while they extract. Adds a Queue view with per-job cancel and drag-to-reorder, and a restored queue waits for the user to start it.

Closes #344, #345, #346, #347, #348, #349, #351, #352, #353.
2026-08-11 21:31:29 +01:00
Tha.Les 41bd89d060 feat(export): name every export after its song (#340)
* feat(export): prefix exported stems with the song title

Stems exported as "bass.wav" or "vocals.wav" are ambiguous the moment they
leave the app. Dropping several songs' stems into one project folder makes
them indistinguishable and they overwrite each other.

Every stem the user receives is now named "<Song>_<stem>.<ext>":

- ZIP members, via a new prefix argument to _build_stems_zip
- Single-stem downloads, via the Content-Disposition filename
- Single-stem region trims, which keep both the song and the _region marker
- The MP3 variant of a stem

The server carries the name because Content-Disposition wins over an
<a download> attribute for same-origin requests, so setting the attribute
alone had no effect. The attribute is set too, as the fallback for any
response that does not send the header.

_safe_title is split into _title_slug, which returns "" for a title that
sanitizes to nothing, and _safe_title, which keeps the "stems" fallback for
the whole-archive filename. A per-file prefix has to be droppable, otherwise
an untitled job yields a leading underscore on every member. The slug is
restricted to [A-Za-z0-9_], so it stays safe as a ZIP member name.

Also fixes the desktop per-stem download, which routed through open_url and
handed the file to the OS handler: the stem opened in a browser or media
player and was never saved, so no filename applied at all. It now goes
through save_audio_file like every other export.

Closes #336

* fix(export): prefix the MP3 region stem download too

Missed in the previous commit: the trimmed-region branch of the MP3 stem
route still built a bare "{name}_region.mp3", so it was the one stem file
the user could receive without the song prefix.

Its ternary also had an unreachable branch. Only the trimmed case reaches
that line; the untrimmed one returns from the cached-file branch above.

* fix(export): name the mixdown after the song too

The mixdown endpoint hardcoded filename="mixdown.{ext}", so every song's
mix and every region export downloaded as "mixdown.wav". Exporting a few
songs into one folder produced mixdown.wav, mixdown(1).wav, mixdown(2).wav
-- the same collision #336 reports for stems.

Content-Disposition overrides the <a download> attribute, so the name the
frontend already built was discarded. Only desktop escaped it, because
save_audio_file uses the frontend's name rather than the header.

The video export was already doing this correctly, which left the mixdown
as the only export not named after its song.

Names mirror the frontend's: <Song>_exported_mix.<ext>, and <Song>_region.<ext>
when start/end trim to a loop region.
2026-08-09 08:43:18 +01:00
Tha.Les 30788531b2 feat: click track with beat grid detection and editor (#334)
Adds a click track locked to a per-track beat grid, an editor for correcting that grid, an opt-in to include the click in exports, and a Settings > Logs tab.

Detection uses beat_this (MIT code and weights) with librosa as an offline fallback, because librosa's 120 BPM tempo prior resolves a 180 BPM track to 90 and no confidence metric catches it. Click scheduling is locked to the engine's source time domain and measured at 0.000 ms error over 70 s of continuous playback.
2026-08-08 21:45:19 +01:00
Tha.Les c0e4f72169 feat: OGG and Opus support — import upload and OGG export (#331)
Import: accept .ogg (Vorbis or Opus in Ogg) and .opus uploads. The
pipeline already transcodes every local upload to 16-bit/44.1 kHz WAV
via ffmpeg before Demucs, so only the extension allow-lists change:
the API gate, the web file picker/drop validation, and the mobile
accept list (which already advertised .ogg but got a server 422).

Export: add OGG (Vorbis VBR q6, ~192 kbps — the quality tier matching
the MP3 setting) to the mixdown, region, and stems-zip endpoints plus
the export format toggle in the player.

Tests: the unsupported-extension fixtures used .ogg and now use .aiff;
new upload tests for .ogg/.opus and an ffmpeg-gated OGG zip transcode
test asserting real OggS output.

Closes #330

Co-authored-by: Thales <>
2026-08-05 12:56:47 +01:00
Tha.Les 679eb78fa1 perf(api): cache mixdown renders (#311)
* perf(api): cache mixdown renders (#290)

Identical mixdown params re-ran the full ffmpeg graph on every request.
On a shared server, repeat downloads of the same export (a common case)
burned CPU for a pure function of the inputs.

_stream_ffmpeg optionally tees yielded chunks to a per-request temp file
as it streams; a clean finish atomically renames it into place as the
cache entry and prunes the cache to a 20-file / 500 MB budget (oldest
first). Any failure or client disconnect removes the temp file instead
-- a render the client didn't get in full never becomes a cache hit for
the next request.

get_mixdown's cache key covers every render input (job_id, ext, stems,
gains, region, and the live export sample rate setting), computed after
the existing job/stem validation so a deleted or not-ready job still
404s the same way it always has instead of serving a stale entry. A hit
returns a FileResponse with no ffmpeg invocation at all.

Also: cache/ (CACHE_DIR's default under the repo root for source runs,
same pattern as jobs/) wasn't gitignored -- added it alongside jobs/.

* fix(api): silence bandit B324 on the cache-key sha1 (not a security use)

* address code-quality review: log prune failures, unify import style

- _prune_mixdown_cache: log a debug line instead of silently swallowing
  a failed unlink, so a stuck cache entry leaves a trace.
- tests/test_stems_api.py: use "from app.api import stems as stems_mod"
  consistently instead of mixing it with "import app.api.stems as ...".

---------

Co-authored-by: Thales <>
2026-07-17 14:50:58 +01:00
Tha.Les c896b9cfae perf(events): SSE dirty-flag + tear-proof job serialization (#305)
Adds Job.version, bumped by _set() on every field write. The SSE stream
now compares versions instead of re-serializing + string-diffing on every
0.2s tick -- idle connections drop from a full to_state()+json.dumps per
tick to one int compare, eliminating ~1,000 serializations/s at the
200-connection cap.

Also closes #285 for real: if job.version changes while to_state() is
mid-call, the snapshot may mix pre- and post-write fields (a torn read).
The stream loop now detects that (version read before vs. after
serializing) and discards the snapshot instead of yielding it, retrying
immediately.

Already-terminal jobs (done/error/cancelled) now close the stream right
after the initial snapshot instead of idling.

Closes #289

Co-authored-by: Thales <>
2026-07-17 11:53:31 +01:00
Tha.Les a666b39497 fix(api): log ffmpeg stderr when a streamed render fails (#297)
Streamed ffmpeg renders (mixdown export, region trims, stem MP3, video
mux) sent stderr to DEVNULL. When ffmpeg died mid-stream the client
received a truncated file with HTTP 200 already committed -- and no
trace of the failure existed anywhere, making "my export is broken"
reports unsolvable.

stderr is now drained into a bounded tail (mandatory anyway once it is
a pipe -- an undrained full pipe would deadlock ffmpeg) and logged at
WARNING with a per-endpoint context (job id, format, stems) when the
process exits non-zero. Kills we initiated on client disconnect are
expected and stay silent; EOF-then-nonzero is the failure signature,
since returncode stays None until wait() even for an exited child.

Closes #280

Co-authored-by: Thales <>
2026-07-17 01:20:59 +01:00
Tha.Les 3359ed070a feat(settings): export sample rate option + reorganize settings tabs (#270)
* feat(settings): export sample rate option + reorganize settings tabs

Add a configurable export sample rate for mix/region downloads (WAV/FLAC/
MP3), addressing hardware samplers (e.g. Akai MPC) that reject 44.1 kHz.
The rate is a runtime setting read live by the mixdown endpoint, applied
via ffmpeg -ar; default 44.1 kHz (the stem rate) is a no-op.

Reorganize the Settings dialog into General / Network / Export tabs:
- General: max track length, compute device, out-of-sync tracks
- Network: availability toggle + QR, Port (moved here)
- Export: sample rate, MP4 video quality (moved here)

Also:
- Port field now shows the live serving port, not the stale saved
  preference (editing still saves the preference for next restart).
- In server mode the network toggle renders on + read-only, with an
  inline note explaining it is governed by server configuration.

* fix(settings): keep the dialog a uniform size across tabs

Pin the settings dialog to a fixed height and let every pane fill it
(flex:1), so switching between General / Network / Export no longer
resizes the dialog. The General pane scrolls within the fixed area.

Refs #271
2026-07-16 15:33:44 +01:00
Tha.Les 32bdc38180 feat(settings): QR codes for network access (#238)
* feat(settings): QR codes for network access addresses

When server mode is on, show a scannable QR code for each local IP in
the desktop settings panel. Each QR encodes http://{ip}:{port}/mobile/
so the phone camera opens the mobile UI directly.

- Add segno (pure Python, no PIL) as a new dependency
- Add GET /api/qr?url=... endpoint that returns an SVG QR code
- Render one QR card per LAN address in the network settings section

* feat(settings): remove IP list, blur QR codes with tap-to-reveal

- Drop the yellow IP address chips; the QR label already shows the URL
- QR codes start blurred so a nearby camera app can't scan them
  immediately; tap any card to toggle the blur
- Add a hint line: "Blurred so your camera doesn't get too excited. Tap to reveal."

* fix(settings): increase gap between QR cards

* fix(settings): clip QR blur bleed with overflow hidden wrapper

* fix(settings): accent color border on QR cards

* fix(settings): thicker accent border on QR cards

* fix(settings): box-sizing border-box on QR wrap to stop corner clipping

* fix(settings): advanced pane scrolls so Done footer stays fixed at bottom
2026-06-29 19:19:59 +01:00
Tha.Les cde1739c64 feat: mobile web UI + network access toggle (#231)
* feat: mobile web UI + network access toggle

Add a phone-optimized web UI and let other devices on the LAN reach a
StemDeck instance, so the app is usable end-to-end from a phone.

Mobile UI (static/mobile/, vanilla JS to match the stack):
- Library, Mixer, and Extract screens wired to the real API. Library lists
  /api/jobs with swipe-to-delete; Mixer reuses the desktop Web Audio engine
  (audioEngine.js, now accepting a shared gesture-unlocked AudioContext for
  iOS) with faders/mute/solo/seek, real analysis, and mixdown/MP4 export;
  Extract submits URL/upload and follows SSE progress.
- Served by a user-agent check on "/" (phones get mobile, everyone else the
  DAW; ?ui= overrides). Shared DOM-free helpers in static/js/shared/jobs.js.
- Ported from the design prototype kept under design/mobile/.

Network access (app/core/settings.py, app/main.py):
- Backend always binds 0.0.0.0; a runtime gate decides whether non-host
  requests are served (default off, opt-in). The host machine (loopback or
  its own LAN IP) is always allowed, so it can't be locked out.
- Settings dialog reorganized into General / Advanced tabs: General holds
  max track length (<=20 min) and MP4 video quality; Advanced holds the
  network toggle (with the LAN address list) and out-of-sync resync.
- Runtime settings (allow_network, max_duration_sec, video_max_height) are
  persisted and read live via GET/POST /api/settings, no restart needed.

Performance: stem MP3s are transcoded once and cached on disk (was re-encoded
on every request), so loading a track on mobile is fast and re-loads instant.

Desktop: start_backend binds 0.0.0.0; adds a local_ip command.

* chore: address code-quality bot — document suppressed excepts; untrack design refs

- _local_ips() and settings _load()/_save(): replace bare `except: pass` with
  an explanatory comment + logging.debug/warning(exc_info=True); behavior
  unchanged (still best-effort).
- _load(): handle the no-file case explicitly (FileNotFoundError) vs. logging
  genuinely corrupt files.
- Untrack design/ (the imported Claude Design prototype) and gitignore it — it's
  a local spec reference, not shipped code, and the static analyzer's "no-effect
  expression" flags on its <x-dc> template bindings were false positives.

---------

Co-authored-by: Thales <>
2026-06-27 19:04:28 +01:00
Tha.Les 9ca03fc4d1 chore: MP4 wording cleanup + "We Recommend" rename/polish (#228)
* chore: drop "karaoke" wording from the MP4 export

The video export is just an MP4 export, not specifically a karaoke
feature. Replace all "karaoke" references in UI strings, the download
filename, comments, docstrings, and docs with neutral MP4/video wording.
No behavior change.

- UI: MP4 "Export Mix" subtitle -> "Export mix with the original video".
- Download filename: <title>_karaoke.mp4 -> <title>_video.mp4 (frontend
  download attr and backend Content-Disposition).
- Comments / docstrings / README updated; no renamed identifiers
  (downloadCurrentVideo, /video.mp4, has_video were already neutral).

* chore: rename "Supporters" UI label to "We Recommend"

Match the README "We Recommend" section. "Supporters" implied a
sponsorship relationship the project explicitly does not have (no money
or funding accepted); these are editorial recommendations of makers and
artists. Updates the rail button (title/aria-label/chip) and the dialog
heading. Internal ids stay friendsBtn/friendsTitle.

* feat: polish "We Recommend" and add Thomann + Analog4Lyfe

- Stack the rail label onto two centered lines ("We" / "Recommend") so it
  no longer clips the 40px chip, and swap the TV icon for a heart (both the
  rail button and the dialog header).
- Add a monogram avatar fallback: tiles with no image (or a broken image)
  render an on-brand circular initial instead of a broken-image icon.
- Add two recommendations: Thomann (@thomann.music) and Analog4Lyfe
  (@analog4lyfe), in the dialog grid and the README table. Their images
  (static/img/friends/{thomann,analog4lyfe}.jpg) can be dropped in later;
  until then they show the monogram fallback.

---------

Co-authored-by: Thales <>
2026-06-26 12:15:46 +01:00
Tha.Les da93c5ee44 feat: export as MP4 (karaoke video) for MP4 uploads and YouTube (#226)
* feat: export as MP4 (karaoke video) for MP4 uploads and YouTube (#219)

Add an MP4 export that muxes the current mixer state (e.g. vocals muted)
with the source video, producing a karaoke-style video.

Backend:
- Preserve a silent video.mp4 from .mp4 uploads (stream-copy, no re-encode).
- YouTube jobs do a best-effort video-only download (H.264/avc1, <=720p)
  to video.mp4, decoupled from the audio source so failures degrade to
  audio-only. New STEMDECK_VIDEO_MAX_HEIGHT config.
- GET /api/jobs/{id}/video.mp4 streams a fragmented MP4: the amix audio
  graph encoded as AAC, video stream-copied.
- has_video flag on Job, surfaced in state and persisted to metadata.

Frontend:
- MP4 added as a fourth export format (WAV/MP3/FLAC/MP4), shown only for
  jobs with a preserved video track. In MP4 mode, Export Mix produces the
  karaoke video and the audio-only Stems/Region rows are hidden.

SoundCloud and plain audio uploads are audio-only (no MP4 option).

* feat: bundle FFmpeg on Linux via first-launch download

Linux no longer requires `sudo apt install ffmpeg`. The desktop shell now
downloads a static FFmpeg build into the user data dir on first launch
(like Windows/macOS), falling back to a system ffmpeg on PATH when present.
This also fixes Demucs failing to decode compressed sources, since the
download lands in data_dir/ffmpeg which config.json already adds to PATH.

- ensure_ffmpeg: prefer a system ffmpeg, else download_linux_ffmpeg.
- download_linux_ffmpeg: fetch the .tar.xz, extract with system tar,
  copy ffmpeg + ffprobe into data_dir/ffmpeg. STEMDECK_FFMPEG_URL overrides.
- Widen download_file and make_executable from macos to unix so Linux
  reuses them.
- Not bundled in the tarball, so we don't redistribute FFmpeg.
- Update Linux README/notices/packaging comment to drop the ffmpeg apt step.

* style: apply ruff format to MP4 export code

---------

Co-authored-by: Thales <>
2026-06-25 22:46:23 +01:00
Tha.Les e817fa7839 feat: add MP4 and M4A upload support, raise limit to 400 MB (#210)
Closes #209
2026-06-18 09:23:27 +01:00
Tha.Les 0a67593ad4 feat: add FLAC support (import and export) (#flac) (#194) 2026-06-05 15:36:49 +01:00
Tha.Les 7b566e1c2e feat: Export Mix reflects the mixer (volume, mute, solo) (#183) (#191)
Export Mix now renders on demand from the current mixer state - per-stem volume, mute, and solo - via a new /jobs/{id}/mixdown.{ext} ffmpeg endpoint. Master fader is intentionally excluded; Export All Stems stays raw. Adds 13 backend tests; verified end-to-end (gain 0.5 -> half RMS, amix sums faithfully).
2026-06-05 12:49:55 +01:00
Tha.Les bfa41c1794 feat: consolidate footer export into one dropdown with stems .zip (#162)
Replace the two stacked export split-buttons (Export Mix / Export Region,
each MP3/WAV) with a single "Export Mix" button whose dropdown lists the
actions, plus a WAV/MP3 toggle in the panel header.

Frontend (static/):
- One split-button + menu: Export Mix, Export All Stems, Export Current Region
  (icon + title + description). The whole button opens the menu; the caret is a
  decorative indicator.
- WAV/MP3 toggle applies to whichever action is picked.
- Export Current Region disables (aria-disabled) until a loop region exists;
  updateLoopRegionVisual() repointed to the menu item.
- Export Mix / Export Stems operate on the ACTIVE (selected) stems only, not all
  six — the stems action passes the active set to the backend.
- Stem-mix exports keep song-titled filenames; brief "Exporting…" busy state.
- Keyboard: arrow nav, Esc-to-close with focus return; role=menu/menuitem.

Backend (app/api/stems.py):
- New GET /jobs/{id}/stems/all.zip?format=wav|mp3&stems=… — streams a single
  ZIP named after the song, scoped to the requested (whitelisted) stems. WAV
  files are stored as-is; MP3 is transcoded per stem via ffmpeg in a worker
  thread. Stdlib zipfile only (no new dependencies); temp file + cleanup, full
  path/validation guards.

Tests: 6 cases for the zip endpoint (subset scoping, default-all, bad format,
unknown stem, malformed/unknown job, no stems, mp3 transcode).
2026-05-29 14:59:08 +01:00
Tha.Les 0830246f63 feat: SoundCloud support + instant waveform rendering from pre-computed peaks (#158)
* feat: add SoundCloud support alongside YouTube

* chore: sync uv.lock with fastapi !=0.136.3 exclusion

* feat: pre-compute waveform peaks server-side for instant rendering

Pipeline now writes peaks.json after stem separation. Frontend fetches
it on track load and renders overview + footer waveforms immediately,
before audio is ready to play — eliminating the multi-second WAV decode
wait. Falls back to client-side decode for old jobs without peaks.json.

- compute_stem_peaks() in collect.py: soundfile + numpy, 1500 [min,max]
  pairs per stem, atomic write via temp+rename
- GET /api/jobs/{id}/stems/peaks.json with immutable cache header
- wireUpAudio async: 3s timeout peaks fetch, stale-token guard
- 14 new tests (SoundCloud URL validation, peaks endpoint, unit tests)

* fix: remove unused pytest import in test_pipeline_collect

* feat: show catalog tracks as unavailable when job data is gone server-side

When GET /api/jobs/{id} returns 404, mark the track status "unavailable",
persist it, update the status dot to grey, and dim the track meta. On
subsequent clicks, surface the error immediately without a server round-trip.

Closes #157

* fix: keep mixer visible during track import

* fix: don't await peaks fetch before Multitrack.create — fixes choppy audio in WKWebView

* fix: move New folder button below Stem Collections heading

* fix: defer overview waveform render to canplay to prevent WKWebView audio choppiness

Pre-computed peaks were rendering overview waveforms ~100ms after Multitrack.create
via _peaksPromise.then(), blocking the main thread during WKWebView's audio startup
window and causing buffer underruns. Moves all overview rendering to the canplay
handler (matching v0.6.0-alpha.6 timing), with a _canplayFired flag to handle the
edge case where canplay fires before peaks.json resolves.

* fix: pre-fetch peaks.json in parallel with job data to avoid Safari connection limit

In v0.6.0-alpha.6, initFooterWaveform fetched original.wav (same URL as a stem),
so browsers could coalesce the duplicate request and stay within Safari's
6-connection-per-origin limit. The peaks feature replaced that with a unique
peaks.json URL, pushing concurrent connections to 7 and causing one stem WAV to
queue — audio started before that stem was buffered, producing stutter on Safari.

Fix: start the peaks.json fetch in catalog.js in parallel with the job-data fetch,
before wireUpAudio is called. By the time Multitrack.create fires its WAV fetches,
peaks.json is already resolved and its connection slot is free.

Also adds _canplayFired guard to handle the edge case where canplay fires before
peaks resolve, and accepts peaksPromise as a parameter in wireUpAudio so no second
fetch is needed.
2026-05-28 16:08:51 +01:00
Tha.Les c474d522a2 feat: export selected loop region as WAV/MP3 (#109)
* chore: open branch for issue-108 (export loop region)

* feat(#108): export selected loop region as WAV/MP3

- stems.py: add optional ?start=&end= query params to WAV and MP3
  endpoints; when present, pipe through ffmpeg atrim+asetpts before
  returning so the download is cropped to the loop region
- index.html: add Export Region chip (hidden by default) to the left
  of Export Mix in the footer transport bar
- transport.js: show/hide the Export Region chip inside
  updateLoopRegionVisual() whenever loop state changes
- player.js: add downloadRegionMix() and downloadRegionMixMp3() which
  append ?start=&end= to the mix URL and trigger a download
- main.js: wire Export Region dropdown click handlers

* feat(#108): polish export region UX and fix silent region export

- Disable Export Region button until a loop region is actually selected
  (was hidden; now grayed out with cursor:not-allowed so users know it
  exists but requires a region first)
- Fix silent exported files: replace atrim filter chain with -ss/-t seek
  options, which are more reliable when streaming to pipe:1
- Remove gold pulsing glow from waveform loading overlay (keep animated
  bars + text on solid dark background as requested)
- Restore solid background on loading overlay so the buffering state is
  visible instead of showing an empty waveform area

* chore: ruff format stems.py
2026-05-23 07:42:39 +01:00
Thales Pereira 1756acbe39 fix: address remaining code-review findings (reliability, security, quality, docs)
Python:
- Replace os._exit(0) in desktop watchdog with os.kill(SIGTERM) for clean uvicorn shutdown (#92)
- Add _MAX_SSE_CONNECTIONS=200 cap with counter in events.py; 503 when full (#86, #88)
- Fix stall watchdog thread-join race in separate.py with threading.Event (#89)
- Extract duplicate pipeline exception handling into _run_async helper (#99)
- Remove STEM_NAMES re-export from app/pipeline/__init__.py (DC-1)
- Add OpenAPI docstrings to all endpoints (#105)

Tests:
- Add tests: error-path job dir cleanup, persist on error, local pipeline cleanup (#82)
- Add test: SSE 503 when connection cap reached (#86/#88)
- Migrate test_registry_persistence.py to autouse fixture pattern (TC-4)

Rust:
- stop_backend: send SIGTERM first, wait 3 s, then SIGKILL (#85)
- free_port: return live TcpListener; drop after spawn to narrow TOCTOU window (#87)
- wait_for_health: exponential backoff 250 ms -> 2 s to reduce busy-polling (#91)
- download_file_with_powershell: pass URL via env var, not string interpolation (#98)
- Add libc dependency for Unix SIGTERM

JS:
- Remove unused noneSelected variable in main.js (DC-2)
- Remove duplicate renderMixerRow import in player.js (DC-3)
- Track _footerPlaceholderResizeObs; disconnect in destroyPlayer (JS-5)
- Replace JSON.parse/stringify deep clone with structuredClone in utils.js (JS-7)

Docs:
- Update README Python version to 3.12 to match run.sh (#104)
- Update stale joblib advisory date to 2026-05-21 (#106)
2026-05-21 10:03:46 +01:00
Thales Pereira 96a822e6e4 fix: address /review-all findings across security, reliability, and code quality
Security (S1-S5):
- catalog.js: add esc() helper and apply to all YouTube-sourced strings in
  innerHTML (track.title, channel, tags, thumb src attribute)
- catalog.js: set aria-label on delete button via setAttribute, not innerHTML
- events.py: add JOB_ID_RE validation (only endpoint that was missing it)
- main.rs: restrict open_url to http/https schemes only

Reliability (R1, R4, R5, R6):
- main.py: store asyncio.create_task() refs so GC cannot collect sweep/watchdog
- runner.py: delete job_dir on pipeline error, same as cancellation path
- registry.py: add register_if_capacity() for atomic capacity check + register
- registry.py: serialize registry JSON under lock to avoid stale snapshot
- jobs.py: use register_if_capacity() in both YouTube and local-upload paths

Code quality (C3, C6, C7, C14, C19):
- models.py: move _set() out of download.py into models where it belongs;
  narrow Job.status from str to Literal for exhaustiveness checking
- job.js: REST polling is now SSE fallback only, not parallel with SSE
- player.js: close visualAudioContext in destroyPlayer() to avoid context leak
- catalog.js/utils.js: replace all empty catch blocks with console.warn

Documentation (D1, D2, D3, D4, D5, D8, D10, D12):
- collect.py: fix stale sweep_old_jobs docstring (was "per submission", now hourly)
- main.py: pass version and description to FastAPI constructor
- README: add 4 missing API endpoints; remove false range-request claim; add
  9 missing env vars and run.sh vars; fix broken buymeacoffee link; fix badge
  links to use canonical stemdeckapp org

Tests (C4, C5, C11, C13):
- test_jobs_api.py: add sections endpoint tests (happy path, 404, 422),
  file upload tests (extension, empty, mp3, wav), 503 capacity tests for
  both YouTube and upload paths, SSE job_id validation test
- test_stems_api.py: use tmp_path + monkeypatch JOBS_DIR instead of writing
  to real jobs dir; remove manual directory cleanup from tests
2026-05-21 09:06:52 +01:00
Tha.Les 4f571de38e fix: normalize all WAV uploads through ffmpeg and fix Docker jobs permissions (#76)
* fix: normalize all WAV uploads through ffmpeg and fix Docker jobs permissions

- runner.py: remove MP3-only guard in _prepare_local_source; now all local
  uploads (WAV and MP3) are transcoded to 16-bit 44.1 kHz stereo WAV before
  Demucs. Professional WAVs (24-bit, 32-bit float, high sample rate,
  multi-channel) were silently processed by Demucs and output as silence.

- Dockerfile: add gosu, add entrypoint script that re-chowns /app/jobs before
  dropping to the app user. Fixes PermissionError when Docker creates the
  bind-mount host directory as root on first run.

Fixes #75

* chore: update uv.lock for requires-python <3.14 bound

* fix: remove invalid StreamReader.close() call in MP3 stem streaming

asyncio.create_subprocess_exec sets proc.stdout to asyncio.StreamReader,
which has no .close() method. The call crashed every MP3 stem export.

Also kill ffmpeg if still running on early exit (client disconnect).

* chore: bump version to 0.5.0-alpha.3 in pyproject.toml and version.json

* ci: ignore new torch/joblib PYSEC advisories blocked by torch <2.7 pin

* fix(ci): suppress trivy DS-0002 for gosu entrypoint privilege-drop pattern

* chore: sync uv.lock with 0.5.0-alpha.3 version bump
2026-05-21 08:19:36 +01:00
Tha.Les c30dc6fa45 feat: export mix, footer waveform, tag search, pipeline fix (#69)
* feat: export mix, footer waveform fix, tag search, and pipeline fix

- WAV/MP3 export working with named files ({title}_exported_mix.ext)
- Export button grayed out when no track loaded
- Pipeline always produces mix.wav including all-stems jobs
- Footer waveform uses mix_url source instead of stems[0]
- Tag search with #tag autocomplete dropdown in library sidebar
- Tauri export uses open_url (WKWebView doesn't support <a download>)

* fix(lint): ruff format app/api/stems.py and app/pipeline/analyze.py
2026-05-18 21:24:14 +01:00
Tha.Les 04c0b4dee1 feat(ui-refactor): DAW redesign, library overhaul, sections, analysis, and transport (#67)
* feat(ui): redesign frontend to flat dark DAW aesthetic

Replace the glassmorphism panel layout with a flat, dark DAW-style UI
matching the Stemdeck-static.html reference design.

- Rewrite index.html: new topbar with composer pill (URL zone + stem
  chips + Process button), sidebar rail, track header with energy bars
  and Key/BPM/LUFS analysis, section ribbon, waveform ruler, horizontal
  mixer lanes, and transport footer
- Add daw.css: complete new design system using .daw prefix; flat solid
  surfaces, stem color-mix() chips, horizontal mixer lane rows styled
  for the JS-built .lane-header.mx-row elements
- Update variables.css: new palette tokens (--bg, --bg-2, --panel,
  --border, stem colours, --accent) with legacy compat aliases for
  waves.css

All 65 JS-required IDs and hooks (.app, .url-wrap, .stem-choice,
.stem-list, .mixer-column, .energy-row, etc.) are preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): topbar and composer cleanup, notification panel, layout tweaks

- Remove duplicate SVG logo; keep text-only wordmark
- Add "All" toggle before stem chips in composer pill
- Expand composer pill max-width 920→1080px
- Replace topbar "new release available" text with notification panel
  under bell button (dropdown card, outside-click close, version relay
  via MutationObserver from brandVersion element)
- Track header grid: info card 320→380px, energy panel fixed 200px
- Hide .daw-version from topbar (content surfaced in notif panel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): expand composer pill to full topbar width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): restore waveform artstyle and wire All stem toggle

- Remove daw.css overrides for .wave-scroll/.wave-canvas/.waves-grid/.loop-region
  that shadowed waves.css's dark background, golden scrollbar, and loop marker
  visual styles; only keep layout wrapper (.daw-wave-panel flex column)
- Add .daw .wave-scroll flex:1 override so wave fills full panel height
  (zoom toolbar now lives in .daw-wave-header outside .wave-editor)
- Add wireAllButton() in main.js: toggles all stems on/off via selectedStems,
  syncs aria-pressed state when individual chips are clicked
- Remove data-stem="all" from All button to avoid main.js stem handler picking it up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): remove waves.css wave-editor padding/radius in flat layout

waves.css applies padding:12px 14px 14px and border-radius:12px to any
.wave-editor element. Our .daw-wave-panel.wave-editor was inheriting
those, creating a visible gap/frame around the waveform canvas instead
of a flush edge-to-edge dark panel.

Strip padding, min-height, and border-radius via !important overrides
so the wave-scroll fills the panel completely with its dark background
and golden scrollbar as intended by waves.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): restore SVG waveform layer — fix height chain and remove 48px offsets

waves.css hides #multitrack-container (opacity:0) and shows the custom
stem-waveform-layer (SVG min/max peaks) in the active state. Two issues
prevented this from working in our new layout:

1. Height chain broken: waves-column used height:calc(100%-34px) but
   wave-canvas had no explicit height, so the percentage never resolved.
   Fix: .daw .wave-canvas { height: 100% }.

2. 48px inner stem strip: the original layout had a 48px icon strip
   inside the wave panel, so waves.css offsets multitrack-container and
   stem-waveform-layer by left:48px. Our mixer is a separate 300px panel,
   so those offsets must be zeroed out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): show WaveSurfer native bar rendering, hide SVG waveform layer

waves.css hides #multitrack-container (opacity:0, position:absolute) in
favour of the custom SVG stem-waveform-layer. User wants the WaveSurfer
bar style instead (vertical bars with gaps on dark background).

- Force #multitrack-container opacity:1 and position:relative so it is
  visible and contributes to waves-column height
- Hide .stem-waveform-layer entirely
- Set waves-column height:auto so it sizes from WaveSurfer's injected height
- Remove now-redundant inset:0 override (only needed for absolute positioning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): align mixer rows with waveform, fix transport buttons, remove zoom toolbar

- Transport footer: add display:flex !important to override waves.css grid
- Wave/mixer gap: zero border on .daw .wave-scroll and the 48px inset/margin-left
  offsets (waves-grid, lanes-ruler, multitrack-container) that assumed an icon strip
- Double playhead: set WaveSurfer global cursorWidth:0 — CSS .playhead-marker is the
  only active playhead now
- Loop region position: loopOverlayParent() returns rulerTime first so the loop
  overlay lives in the same coordinate space as the time→percent calculation
- Zoom toolbar: removed +/−/slider controls from wave header; zoom is mouse-scroll
  only; Fit button in footer retained; dropped dead zoomInBtn/zoomOutBtn/zoomTrack
  refs that were causing a ReferenceError aborting main.js init (breaking the split
  stems button and all subsequent wiring)
- Mixer row height: 64px → 66px (64 wave + 2px separator) to match WaveSurfer lane
  pitch and eliminate cumulative row drift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): show loop region as full-height waveform overlay

Parent #loop-region to .waves-column instead of rulerTime so the
yellow overlay spans all stem lanes like a real DAW. The % position
calculation remains correct at any zoom level since waves-column width
represents the full timeline extent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ui): remove all zoom capability

Drop zoom controls entirely — Fit button, Ctrl+wheel zoom, zoom state,
and all zoom helper functions. Keep the ResizeObserver (recalculates
--wave-playhead-h on resize) and the plain vertical wheel pan handler.
applyWaveZoom() now only sets --wave-playhead-h and syncs WaveSurfer
pxPerSec to fit-to-viewport (zoom=1 always).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): audio playback, sidebar navigation, and catalog deduplication

- mixer.js: revert to audioEl.volume for WKWebView (MediaElementSource →
  GainNode does not reliably pass audio in Safari); remove GainNode path
- player.js: remove attachAnalysers() import and call — VU meters use
  pre-computed envelope data, not live Web Audio analysis; calling
  createMediaElementSource on canplay was disconnecting Safari's native
  audio output path
- index.html: add rail-library class to Library button so catalog.js
  selectors (.rail-library) actually find it
- main.js: Library button click in trash view switches back to library
  instead of collapsing the sidebar
- catalog.js: normalize YouTube URLs to yt:<videoId> in normalizeSource
  to deduplicate youtu.be/xxx vs youtube.com/watch?v=xxx and variants
  with &t= / ?si= query params; re-importing a trashed track removes it
  from trash and places new import in library
- daw.css: add Empty trash button (clear-bin-bar/btn), hide lib-header
  in trash view, show clear-bin-bar only when sidebar has trash-view class

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(library): subfolders, folder drag-reorder, sidebar fixes, and trash purge

- Subfolders: folders now support parentId; drag a folder onto the
  middle zone of another to nest it, or use the subfolder button on
  hover; delete cascades to children
- Folder drag-to-reorder: grip handle on each folder header; three-zone
  drop target (top/bottom = reorder, middle = reparent as subfolder);
  circular-nesting guard prevents invalid trees
- Sidebar never collapses from within: library button and search input
  no longer trigger collapse; catalogToggle is expand-only
- Unsorted count shows only truly unsorted tracks (not total library)
- Trash purge fix: markJobsDeleted() persists a denylist in localStorage
  before clearing trash; syncWithServer() skips trashed and hard-deleted
  IDs so purged tracks never reappear on reload; DELETE /api/jobs/{id}
  fired for each purged track to remove server files and registry entry
- New folder button redesigned as a labeled chip (icon + text)
- Sidebar width increased to 390px

Closes #38, #39, #40, #41, #42, #43, #44

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ui): stem presence cards panel (Part A)

Replaces the energy bar panel with per-stem presence cards. Backend computes
mean RMS per extracted stem after collect(), normalizes 0-100, stores in
metadata.json and surfaces in the API. Frontend renders one card per stem
in STEM_NAMES order: extracted stems show a colored fill bar + percentage,
non-extracted stems render grayed with "—".

* feat(ui): redesign track info panel to 2-row card layout

Row 1: track card + KEY / BPM / LUFS / DURATION / SCALE metadata cards.
Row 2: 6 stem presence cards (VOCAL PRESENCE, DRUM INTENSITY, BASS DEPTH,
GUITAR PRESENCE, PIANO PRESENCE, OTHER) with large colored percentage values.
Matches the reference design — replaces the old 3-column grid with a full-width
card grid closer to the screenshot.

* feat(analysis): add dynamic range and tempo stability metrics

Dynamic range = peak_db - integrated LUFS (dB), classified as Compressed /
Moderate / High / Wide. Tempo stability = 0-100% from beat interval coefficient
of variation; shown green when >= 80%. Both stored in metadata.json, surfaced
in the API, and displayed as metadata cards in the track info panel row.

* fix(ui): gray out stem presence cards with 0% value

* feat(ui): add favorites heart, Extracted/Source/Quality to track card

Heart button toggles favorite per track (stored in catalog localStorage).
Extracted date derived from created_at (now surfaced in API). Source and
Quality derived from source_url (YouTube vs local file + format).

* fix(ui): All button only active when all stems selected

* fix(ui): gray out non-extracted stems in mixer and waveform

* fix(ui): align waveform rows with mixer by using fixed TRACK_NAMES positions

Multitrack.create() now receives all TRACK_NAMES (7 entries) with url:null
for non-extracted stems. Previously only extracted stems were passed, so
WaveSurfer placed Drums at row 1 (Vocals position), Piano at row 2 (Drums
position), etc. Fixed indices mean each stem always occupies the same row
regardless of which subset was extracted.

* feat(library): redesign sidebar with Recent, Stem Collections, Tags, and Favorites

- Rail: add Favorites button (heart), keep Library + Trash, remove Projects/Storage
- Library view: three sections — Recent (last 3 tracks by date), Stem Collections
  (folders), Tags (chips from track.tags; click chip to filter)
- Favorites view: filtered list of heart-marked tracks, accessible via rail button
- Tags: #tag search prefix filters tracks by tag; clicking active chip clears filter
- Unsorted folder now renders as a standard collapsible folder in Stem Collections
- daw-lib-header simplified to New folder button only (label removed)
- favorites-view hides lib-header and clear-bin-bar via CSS class on sidebar

* feat(sections): interactive sections bar with drag, resize, rename, and persist

- Backend: Job.sections field, PATCH /api/jobs/{id}/sections endpoint with
  pydantic validation (id, name, start/end range, color hex check), loads from
  metadata.json on registry recovery
- sections.js: new module — section blocks positioned by %-time over the waveform,
  drag-to-move with no-overlap clamping, left/right resize handles, double-click
  rename inline input, delete button on hover, + button to add section (auto-opens
  rename), debounced PATCH save, 8-color cycling palette
- UI: sections bar is the existing daw-section-ribbon row (label changed to
  Sections); each block has colored border + colored text, 36px height
- Wired into player.destroyPlayer (destroySections), catalog.loadTrackIntoStudio,
  and job.applyState (initSections on done)

* fix(sections): move Add button into Sections label, add Mixer label to wave header

- Add section button moved from floating inside timeline to the Sections label
  column (HTML static button, wired by initSections); shows as 'Add' with + icon
- Wave header left column now shows 'Mixer' title + 'Drag fader · M/S' sublabel
  (previously empty box)

* fix(sections): persist sections correctly when switching tracks

Two bugs caused sections to vanish on track reload:

1. destroySections() was cancelling the debounced save timer, dropping all
   unsaved changes when the user switched to another track before 600ms elapsed.
   Fix: flush the save immediately before clearing state (JSON.stringify runs
   synchronously before the first await, so the body is captured correctly).

2. loadTrackIntoStudio skipped the API fetch when a track had cached audio/analysis
   data in localStorage, so server-written sections were never loaded. Fix: always
   fetch fresh state from /api/jobs/{id} on every track load.

* feat(sections): saving indicator — spinner while PATCH in-flight, Saved ✓ on success

* fix(sections): persist sections across backend restarts and fix save indicator

- Call registry_persist() after writing sections to metadata.json so
  registry.json stays in sync; sections were lost on backend restart
  because jobs already in registry.json skip the metadata.json recovery path
- Check res.ok in _save() before showing "Saved ✓" to surface HTTP errors
- Silence spurious audio errors for null-URL placeholder waveform tracks
- Update help dialog: correct GitHub org URL and add stemdeck.app link

* feat(library): extract YouTube tags and show in library sidebar

Extract tags and categories from yt-dlp info after download, lowercase
and deduplicate, cap at 8 entries. Stored on Job.tags, written to
metadata.json, served via to_state(). The frontend tag chips and #tag
search filter were already wired to track.tags.

* fix(ui): align mixer rows with waveform by moving original to bottom

STEM_NAMES always occupy WaveSurfer rows 0-5 so mixer lanes stay aligned.
"original" is appended at row 6 only when it has a URL — omitting it when
absent eliminates the phantom 70px gap that shifted all mixer rows down.

Also reorders mixer DOM and overview waveform CSS order to match, and fixes
renderAllMiniWaves to use trackIndex for stem→wavesurfer mapping.

* fix(ui): move original track to top when present, not bottom

When original.wav exists the user expects it at the top (A/B comparison).
Prepend it to orderedNames only when present; STEM_NAMES follow. Omitting
it when absent still prevents the phantom 70px gap from the previous bug.

* fix(ui): show all 6 stem rows when original track is present

The mixer fill loop was capped at STEM_NAMES.length (6), so when original
occupied one slot only 5 stems were shown, leaving a phantom WaveSurfer
row at the bottom with no corresponding mixer row. Cap now scales to 7
when original is present, matching orderedNames in wireUpAudio.

* fix(ui): guarantee waveform loading overlay is visible for at least 900ms

If canplay fires before a browser repaint (cache hit or instant null-URL
resolution), the pulsing glow animation flashed and disappeared in under
one frame. Now the overlay stays visible for a minimum of 900ms so the
user always sees the loading state when opening a track.

* feat(ui): show waveform loading overlay during entire pipeline + load

Previously the pulsing glow only appeared during WaveSurfer decode.
Now it shows immediately when Extract Stems is clicked and stays on
through the full pipeline (download → analyze → separate → mix) until
canplay fires after WaveSurfer finishes loading the stems. Hidden on
error and cancel.

* feat(transport): add time display and export buttons to footer bar

* feat(transport): redesign footer bar with track info, scrub, speed dropdown, and MP3 export

* fix(transport): center-align footer buttons and add visible stop/loop backgrounds

* feat(footer): move track info panel to footer and add placeholder waveform

- Relocate full track card (art, title, time/stems, fav, detail rows) from
  top transport header into the footer left section; all element IDs preserved
  so no JS logic changes needed
- Title extracted as a full-width row above the art+detail body so it
  left-aligns with the thumbnail edge
- Footer waveform renders a dim organic placeholder (sum-of-sines) when no
  track is loaded; replaced by real waveform on track load
- Fix waveform bar background/border so it blends into the footer seamlessly
- Reduce bar count 300→150, pixel-snap positions, tighten gap and amplitude
  for smoother bar rendering
- Footer height set to 200px to accommodate full track card

* feat(ui): topbar sizing, footer heart placement, waveform coverage fixes

- Move favorite heart button next to song title (remove margin-left: auto)
- Topbar height +10% (70→77px); scale up composer pill, URL input, stem
  chips, and Split Stems button to fill the extra space
- Footer waveform: 300 bars edge-to-edge (last bar reaches canvas.width),
  matching bar computation in placeholder wave
- Footer track info panel title left-aligns with thumbnail via full-width
  title row above the art+details body row

* fix(ui): fill mixer and waveform vertical space dynamically

Compute lane height from available panel height at load time so stems
fill the full area between header and footer with no black gap. Deferred
with rAF in renderEmptyShell so clientHeight is measured after layout.

* fix(export): make WAV/MP3 export buttons work

Three bugs prevented exports from working:
- _triggerDownload used <a download> which WKWebView silently ignores;
  now uses open_url Tauri command so the system browser handles the save
- downloadCurrentMix looked for "original" stem which is absent when all
  6 stems are selected; _exportMixUrl now prefers mix_url (the actual
  selected-stems mix) with "original" as fallback
- mix_url from the job API was never forwarded through stateMetadataToTrack
  or wireUpAudio; now mapped and passed from both job.js and catalog.js

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:22:25 +01:00
Tha.Les ed8f138697 fix(papaya): apply improvement cycle fixes across backend and frontend (#35)
Quick wins:
- Replace alert() with showError() for unsupported file type errors
- Centralise timeout constants (TIMEOUT_FFMPEG, TIMEOUT_ANALYZE, TIMEOUT_DEMUCS_STALL) in config.py
- Remove debug console.log from player.js
- Log localStorage quota errors via console.warn instead of silently swallowing

Medium effort:
- Add client-side file size validation (100 MB limit) before upload
- Defer SSE state application by one tick to prevent race with in-progress user actions
- Expose STEM_NAMES via /api/config endpoint; frontend syncs on startup instead of hardcoding
- Add job_id prefix to all pipeline log lines for correlation tracing

Longer term:
- Retry yt-dlp downloads up to 3× with exponential backoff for transient network errors
- Add _migrate() to registry.py so persisted schemas are upgraded incrementally on load
- Surface actionable recovery hints in desktop setup flow error states
2026-05-15 17:22:42 +01:00
Thales 2fdcdfc99a fix: persist source_url in job model so URL/filename shows after app restart
The Job model had no source_url field, so syncWithServer rebuilt tracks
without it. On Windows, where the app restarts between sessions, this
left track.sourceUrl undefined and the URL bar blank when loading a
track from the library.

Now source_url is stored in the job (YouTube URL or 'local:<title>'),
returned by to_state(), and read by stateMetadataToTrack. The URL
input is populated on loadTrackIntoStudio; local file tracks show
just the filename (sans 'local:' prefix).
2026-05-10 10:52:02 +01:00
Thales 9b9be30207 feat: local MP3/WAV file import and README overhaul
- POST /api/jobs now accepts multipart/form-data in addition to JSON;
  branching on Content-Type keeps the YouTube path unchanged
- Uploaded files are validated (extension, size <=100 MB, duration
  <=STEMDECK_MAX_DURATION_SEC via ffprobe) before a job is registered;
  busy-server check runs before any disk write to avoid orphan dirs
- MP3 uploads are transcoded to 16-bit 44.1 kHz stereo WAV via ffmpeg
  before Demucs to avoid silent failures from VBR or non-standard rates
- Pipeline refactored: _run_common() shared by YouTube and local paths;
  run_local_pipeline() added as the async entry point for file jobs
- Frontend detects fileInput.files[0] on submit; sends FormData for
  files and shows an uploading stage during the HTTP round-trip;
  sourceUrl set to local:<sanitized-filename> for duplicate detection
- ffprobe_executable() added to config.py mirroring ffmpeg_executable()
- python-multipart added as a hard dependency (required by FastAPI for
  any file upload regardless of usage)
- README rewritten: Nagi-style badge header, enriched honest comparison
  table, stronger disclaimer framing StemDeck as a stem separator first
2026-05-10 10:35:49 +01:00
Thales 4666e00cf6 Revert "feat: local MP3/WAV file import and README overhaul"
This reverts commit 855eb4587c.
2026-05-10 10:34:23 +01:00
Thales 855eb4587c feat: local MP3/WAV file import and README overhaul
- POST /api/jobs now accepts multipart/form-data in addition to JSON;
  branching on Content-Type keeps the YouTube path unchanged
- Uploaded files are validated (extension, size <=100 MB, duration
  <=STEMDECK_MAX_DURATION_SEC via ffprobe) before a job is registered;
  busy-server check runs before any disk write to avoid orphan dirs
- MP3 uploads are transcoded to 16-bit 44.1 kHz stereo WAV via ffmpeg
  before Demucs to avoid silent failures from VBR or non-standard rates
- Pipeline refactored: _run_common() shared by YouTube and local paths;
  run_local_pipeline() added as the async entry point for file jobs
- Frontend detects fileInput.files[0] on submit; sends FormData for
  files and shows an uploading stage during the HTTP round-trip;
  sourceUrl set to local:<sanitized-filename> for duplicate detection
- ffprobe_executable() added to config.py mirroring ffmpeg_executable()
- python-multipart added as a hard dependency (required by FastAPI for
  any file upload regardless of usage)
- README rewritten: Nagi-style badge header, enriched honest comparison
  table, stronger disclaimer framing StemDeck as a stem separator first
2026-05-10 10:31:10 +01:00
Tha.Les 4630b2e311 feat(registry): persist completed jobs across server restarts (#17)
- Add persist()/restore() to registry: terminal jobs written to registry.json
  atomically (tmp→replace) on every state transition; loaded back on startup
- Recover orphan job dirs on startup: if stems/ exists but job not in JSON,
  reconstruct a done Job from disk and backfill registry.json
- Only done jobs are persisted (not error/cancelled — no usable stems)
- Thread-safe: threading.Lock guards all _jobs/_procs reads and mutations;
  lock released before file I/O in persist() to avoid blocking the event loop
- derive _JOB_FIELDS from dataclasses.fields(Job) so new fields are
  automatically included without a manual list to maintain
- Log warning (with traceback) when registry.json is corrupt/unreadable
- sweep_old_jobs only calls persist() when at least one job was removed
- persist() is resilient to an unwritable jobs dir (logs warning, returns)
- GET /api/jobs list endpoint returns all done jobs sorted by created_at
- Frontend syncWithServer() on startup: fetches job list and adds any jobs
  not already in localStorage to the library sidebar

Closes #15

Co-authored-by: Thales <>
2026-05-09 15:26:38 +01:00
Tha.Les 0adf3c8350 fix(playback): resolve stem silence in WKWebView (#19)
Two bugs prevented audio from playing after stems loaded:

1. stems.py: FastAPI's @router.get does not auto-register HEAD; WKWebView
   sends HEAD to probe audio URLs before streaming, receiving 404 which
   caused MEDIA_ERR_SRC_NOT_SUPPORTED (code 4) on all stems. Switched to
   @router.api_route with methods=["GET", "HEAD"].

2. mixer.js: MediaElementAudioSourceNode does not reliably route audio to
   speakers in Safari/WKWebView — GainNodes were wired and gains set
   correctly but output was silent. Replaced with direct audioEl.volume
   assignment, capping gain at 1.0, staying on the browser's native output
   path.
2026-05-09 15:24:45 +01:00
Tha.Les 5b251ccc3b Windows portable app: dual CPU/NVIDIA builds, DAW UI improvements (#5)
* Add Windows portable launcher scaffold

* readme

* readme  update

* fix

* star thistory theme changed

* feat: responsive layout, parallel setup flow, and window constraints

- Enforce 1440×900 minimum window size in tauri.conf.json
- Rewrite setup.js: parallel workspace+gpu phase, minDelay() for
  guaranteed state visibility, IIFE chains, error cleanup on failure
- Add setup.css step indicators (pending/active/done/error) with
  gold spinner, green checkmark, red X
- Fix stems-panel overflow into transport footer (align-self + height)
- Make transport, appbar, and wave editor fully responsive with
  clamp() and fr-based grid columns
- Remove dead .stem-list span.hidden rule (covered by base.css)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: ignore data/ directory (runtime-generated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: untrack .docs directory from git

* Add Windows portable release workflow

* fix(tests): rewrite yoda conditions in test_config.py

* ci: set UV_LINK_MODE=copy to suppress hardlink warning

* fix: security hardening, reliability fixes, and observability improvements

- Enforce MAX_PENDING_JOBS cap on job submissions (503 when queue full)
- Add done_callback to pipeline task to log any unhandled exceptions
- Add job_id regex validation to DELETE endpoint
- Sanitize pipeline error messages sent to clients (full detail stays server-side)
- Move sweep_old_jobs to hourly background task via lifespan (not per-submission)
- Add demucs stall watchdog: terminate if no stderr output for 30min
- Add SSE connection max lifetime (4h) to prevent zombie connections
- Replace shutil.rmtree(ignore_errors=True) with logged _rmtree helper
- Fix log levels: chroma/key diagnostics downgraded from WARNING to DEBUG
- Add bounds clamping for MAX_DURATION_SEC, JOB_TTL_SECONDS, MAX_PENDING_JOBS
- Remove filesystem paths from /health endpoint response
- Replace innerHTML with safe DOM construction for BPM and confidence in JS

* fix(events): use get_running_loop() instead of deprecated get_event_loop()

* fix(tests): update assertions to match sanitized error and health response

* star history

* feat(desktop): Windows portable app — dual CPU/NVIDIA builds, external links, transport colors

- Move frontend to desktop/ui/ and fix frontendDist to point there (fixes Tauri build)
- Dual portable zip variants: StemDeck-Windows-x64 (CPU) and StemDeck-Windows-x64.NVIDIA
- Strip torch .lib static libraries in StripVenv (-623 MB dnnl.lib alone)
- Force-reinstall CPU torch after main pip install to prevent CUDA wheel override
- Sentinel file data/cpu-only short-circuits GPU detection in ensure_torch_device()
- Add open_url Tauri command + JS click interceptor for Help/Tip external links
- Rename GPU setup step to "Configuring compute device" (accurate for both variants)
- Play button turns green when active, stop button turns red when pressed
- Update CI pipeline for dual Windows variants with manual branch trigger support
- Update README with Windows desktop app section and download variant guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix mixer volume updates getting stuck

Clamp per-track mixer output to the browser-safe 0..1 range and isolate volume write failures so one hot fader cannot leave other channels stale. Keep the existing master-volume fallback when no master fader is present.

* fix(ui): DAW view — tracks fill window height, icons aligned per row

- stem-waveform-layer: top: 0 (waves-column already starts below ruler,
  previous top: 72px pushed waveforms 72px too far down)
- stems-panel: align-self stretch + margin-bottom 6px to match
  waves-column height exactly, keeping icon rows in sync with waveform rows
- ResizeObserver on waveScroll recalculates --wave-playhead-h and
  multitrack pxPerSec on every container resize
- Commit desktop/package-lock.json for reproducible npm ci in CI pipeline

* image:local added for windows builds

---------

Co-authored-by: Thales <>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:06:53 +01:00
Tha.Les 19bfd86cad ci: add Woodpecker pipeline (#4)
* ci: add Woodpecker pipeline (lint, tests, security scans)

* docs: add Woodpecker CI status badge to README

* ci: run on pull_request, push to main, and release

* ci: install dev extras so ruff/pytest are available

* style: apply ruff format

* ci(deps-audit): suppress two torch DoS CVEs blocked by torchaudio pin

* ci(security): non-root Dockerfile user, skip .venv in trivy-fs
2026-05-04 14:21:38 +01:00
Thales Pereira fcdde67063 chore: initial commit 2026-05-04 10:57:08 +01:00