main
38 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9b655d5a07 |
Windows: leaner portable package and opt-in in-app updater (#421) (#423)
* feat(windows): leaner portable package and opt-in in-app updater (#421) Issue #421 asked for Python embedded in a single EXE so updating would not mean copying ~20k loose files over an existing install. A onefile EXE is not viable for this stack (onefile modes re-extract the whole multi-GB payload on every launch, and torch/onnxruntime fight frozen-import hooks), so this addresses the root cause instead: ship less, and stop making users hand-copy a full zip for a release that only changed app code. Leaner package (make-portable.ps1): - Stripping is now unconditional. The -StripVenv opt-in gate was a silent regression risk: nothing stopped a future workflow edit from shipping the unstripped venv with no error. - Also strips stdlib base/Lib/test and per-package test/tests dirs. - Deliberately does NOT strip .dist-info/RECORD. pip needs it to replace a package, and install_cuda_torch pip-installs into this venv on every NVIDIA machine at first run; removing it yields "Failed to uninstall ... missing RECORD file". - Adds a post-strip import check so an over-aggressive strip fails the build rather than a release. Updater (main.rs, catalog.js): - New commands installed_runtime_id, download_app_update, apply_app_update. - Opt-in: the check on launch is unchanged, but download and apply are each an explicit click. It never auto-applies and never interrupts a running job. - Replaces StemDeck.exe and backend/ only. python/ is never touched, because an NVIDIA install rewrites it with CUDA torch at first run and torchDeviceSettled skips ensure_torch_device once the device is cuda, so swapping the directory would silently drop that machine to CPU with no recovery. - The runtime id (uv.lock + interpreter major.minor) is a compatibility gate, not a download trigger: if a release changed the Python dependency set the updater stands down and points at the full download. Only 19 of the last 200 commits touch uv.lock, so the fast path covers most releases. - apply_app_update stages and validates everything before any destructive rename, stops the backend synchronously first (the existing stop_backend returns before the process dies, which would have made every update fail on Windows), and retries renames past transient AV/indexer handles. - Known gap, documented in code: the two exe renames are not atomic. A hard crash in that window leaves StemDeck.exe.old needing a manual rename. Closing it needs a bootstrap launcher that is never itself replaced. CI publishes -app.zip, its .sha256 and -runtime-version.json alongside the unchanged full zips. Fresh installs are unaffected. i18n: the 5 new strings are translated into all 7 language tables, not just English. t() falls back to English silently, so an English-only key looks correct in testing and ships untranslated to six locales. Verified: Windows and Linux (WSL) both compile clean with no new clippy warnings, 39 Rust tests pass on both, JS suites pass, ruff clean. Two new unit tests pin the JSON contract between the PowerShell writer and the Rust reader. Not yet verified: no end-to-end run against a real release. * fix(updater): make the in-app update actually work, verified end to end (#421) Built both packages on a real Windows box and drove the whole flow. Four bugs that only surfaced by running it, none of which static checks could see. 1. Stale version after updating. app_version() read installed dist metadata, which lives in python/ -- the directory the updater deliberately never replaces. A self-updated install kept reporting the old version and would re-offer an update it had already applied, forever. It now prefers the app layer's static/version.json, which moves with backend/. Gitignored, so Docker and source checkouts still fall through to the hatch-vcs metadata. Proven: after a real update, python/ dist-info says 0.13.0 while /api/health reports 0.13.1. 2. The page CSP blocked the whole feature. The UI is served over http by the Python backend, so its connect-src applies: api.github.com is allowed, github.com and objects.githubusercontent.com are not, and that is where release assets live. Fetching the checksum and runtime id from JS was refused, so the pill would simply never appear. Those two reads moved into Rust (check_app_update), whose HTTP client is not bound by the page CSP, so the policy from #171 stays exactly as tight as it was. 3. plugin:event|listen refused by the Tauri ACL. App-defined commands are not ACL-gated but plugin commands are, and the capability does not cover the remote http origin the UI is served from. The progress bar is now indeterminate instead of granting a remote origin event permissions to put a percentage on a 5 MB download. 4. The post-strip import check re-bloated the package. Running Python regenerated 1,912 files / 39 MB of __pycache__ that the strip had just removed, cancelling nearly all of it: the net saving was 180 files. Swept once after the last interpreter run, and backend/ no longer ships a developer's local __pycache__ either. Also: the *.old sweep now runs on every launch rather than only on a version change. apply_app_update relaunches then exits, so on the first launch of the new build Windows still holds StemDeck.exe.old open, the delete fails silently, and gated on a change that already happened it would never retry. Observed for real: 15.7 MB stranded. Verified swept on the next launch. UI: "Update now" is an accent pill BESIDE Download, not a replacement, so the zip stays one click away and is the escape hatch if an update fails. Measured against the published v0.13.0 package: 18,143 -> 16,056 files (-2,087, -11.5%) and 883 -> 850 MB. The real win for #421 is the update path itself: 5 MB and 123 files instead of 284 MB and 16,056. Verified on this machine: a real 6-stem Demucs separation through the stripped package; the full notify -> Update now -> download -> restart -> relaunch cycle, after which user data (job, 7 stems, 130 MB of models), portable.txt, cpu-only and python/ were all untouched; and the safety gate correctly declining, with no download attempted, when the release's runtime id differs. Not covered: the NVIDIA package was not built, though the risk that motivated the gate is structurally gone now that python/ is never swapped. * fix: address code-quality review on the version-source change (#421) Both findings from the automated review were fair. Narrow the bare `except Exception: pass` in app_version() to (OSError, ValueError, AttributeError). That is bandit B110, which this repo's own security conventions call out. The three cover every real failure here -- absent or unreadable file, invalid JSON or bad encoding, and valid JSON that is not an object so has no .get -- while letting an actual bug in the function surface instead of silently degrading the reported version. Bandit now reports no issues for the file. Use one import style in test_health_api.py so app.main is no longer imported both as `import app.main as main` and `from app.main import app` in the same module. Also added a "[]" case: JSON that parses but is not an object, which is the AttributeError branch the narrowed except now names explicitly. * feat(updater): extend the in-app update to Linux (#421) Linux ships the same shape as Windows -- executable, backend/ and python/ side by side -- so the updater generalises rather than needing a second design. The platform-specific parts are now three small seams: the archive format, the executable name, and one new gate. Rust: - widen the updater's cfg gates from `windows` to `any(windows, linux)`, and replace extract_zip_archive with extract_update_archive, which uses zip on Windows and the existing extract_tar_archive on Linux - APP_EXE_NAME so the swap and the leftover sweep stop hardcoding StemDeck.exe - stop_backend_and_wait now sends SIGTERM and waits before escalating on unix, matching what stop_backend already does on window close - new app_root_is_writable gate: packaging/linux/install.sh offers a global install into /opt/stemdeck, which is root-owned while the app runs as the user. check_app_update declines up front rather than failing part way through a swap. Windows portable installs are user-writable by construction, but the probe is cheap and honest on both. tar rather than zip on Linux is deliberate: it preserves the executable bit. A zip would land StemDeck without +x and the relaunch after an update would fail with a permission error. Packaging (scripts/linux/make-portable.sh): - write python/runtime-version.json using the same uv.lock + interpreter major.minor formula as the Windows script, so the compatibility gate behaves identically on both - bring the strip to parity: stdlib test/, per-package test/tests, a post-strip import check, and a final __pycache__ sweep after the last interpreter run - PUBLISH_UPDATER_ASSETS=1 emits the slim app-layer tarball, its checksum and the runtime marker; wired into the CPU build in linux-release.yml since StemDeck and backend/ are identical between both variants Frontend: updaterAssetNames() maps the platform to its asset names, and the wiring is gated on that rather than on os === "windows". macOS is deliberately still excluded, and the comments now say why rather than just that it is: backend_dir() resolves the backend inside the downloaded runtime pack rather than the .app, so its app layer is a different thing and the existing runtime-pack updater already covers most of it. Verified: both platforms compile clean with no new clippy warnings, 42 tests on Windows and 43 on Linux (the extra one is the read-only-root gate, which is meaningless on Windows). The app-layer archive was round-tripped on Linux to confirm it contains exactly StemDeck + backend/, that python/ does not leak into it, that the executable bit survives, and that replacing a running binary works. Not yet run end to end against a real Linux release. * fix(updater): see pre-releases, and compile the Rust in CI (#421) Two gaps that would each have undermined the update flow on release day. The update check polled /releases/latest, which GitHub defines as the most recent NON-PRERELEASE, non-draft release. Ship a version with the pre-release box ticked and it becomes invisible: no notification, no update button, on any platform, with nothing in the logs to explain it. StemDeck has always published even its alphas as normal releases (v0.8.0-alpha.17 has prerelease=false), which is the only reason this has not bitten yet -- it was a trap waiting on someone ticking a box. Now polls the releases list and takes the newest non-draft, so it is correct either way. Drafts stay excluded: they are already invisible unauthenticated, and a maintainer should not be offered a release whose assets do not exist yet. windows-check.yml and macos-check.yml now also run on pull requests that touch desktop/src-tauri/**, not workflow_dispatch only. This PR added roughly 600 lines of mostly cfg-gated Rust across two commits and every CI check passed without compiling a single line of it; the comment at the top of windows-check.yml notes that exact gap already shipped a broken Windows build in v0.11.1's first release attempt. Scoped by path so the self-hosted runners see no extra load from the majority of PRs, which never go near src-tauri. This also gets the macOS branch compiled for the first time. Local verification covered Windows and Linux, so the cfg(not(any(windows, linux))) arm of the three updater commands has never been near a compiler. * test(e2e): match the releases-list shape the app now polls (#421) The update-check stub returned a single release object, which was right for /releases/latest. The app now polls the releases list so a pre-release is still seen, so the fixture has to return an array or checkForUpdate bails and the release card never appears. Caught by frontend-e2e on the previous commit, which is the suite doing exactly its job: the only assertion that covers this path is report-failure.spec.mjs:98, and it went red immediately. * feat(i18n): add French, and make the runtime id line-ending independent French is a complete table, not a partial one: 435 keys, the same set German and Portuguese carry (English's 443 minus the ten Polish-only .few/.many forms and the bare upload.skippedFiles, plus singular forms for the three playlist.skip.* families). French takes the one/other buckets, so plural() needs no change. Verified with the checks from .claude/rules/i18n.md: the drift check reports clean, and separately there are zero {placeholder} mismatches and zero HTML tag mismatches against English. The 27 strings identical to English are genuinely identical in French (Piano, Solo, Transport, Position, LUFS, Standard, Port, the brand names, CUDA (NVIDIA), MPS (Apple Silicon)). Separately: the runtime id was being computed from the raw bytes of uv.lock, so a Windows checkout with core.autocrlf=true hashed CRLF and Linux hashed LF, and the same lockfile produced two different ids -- caught by building the Linux package and seeing py3.12-d74d6ef80c5e9d1f where Windows had produced py3.12-dbda45e38e1044cf. Each platform stayed self-consistent so the gate still worked, but the id would shift spuriously if a runner's autocrlf ever changed, silently declining app-only updates that were in fact compatible. Both scripts now hash the content with newlines normalised; PowerShell, bash and a reference Python implementation all agree on d74d6ef80c5e9d1f. * chore: pin Unraid template to 0.14.0 Per .claude/rules/unraid-template-version.md this is an explicit decision each time, not a default. Confirmed for this release. The 0.14.0 GHCR image is published by docker-publish.yml when the release is created, so the tag exists shortly after this lands. --------- Co-authored-by: Thales <> |
||
|
|
b1acc5b5b3 |
Add German, Portuguese, and Indonesian translations (#415)
* Add German, Portuguese, and Indonesian translations; fix i18n coverage gaps Extends the existing English/Polish/Japanese/Simplified Chinese i18n system to seven languages total. Also fixes several pre-existing i18n coverage gaps found while auditing: the recent-tracks list, search placeholder, and trash empty-state were hardcoding English text instead of using the translation system; a presence-panel legend lacked data-i18n attributes; upload/job/ playlist error toasts were untranslated; and library list content did not refresh on a live language switch. Widened the settings dropdown to fit the longest new language name and the device-select to stop truncating longer translated values. Bumps the Unraid template pin to 0.13.0. * Remove unused plural import in job.js Flagged in PR review: job.js imports plural from i18n.js but never calls it, only t(). * Native-speaker QA pass on all translations Fixes real mistranslations (German "schleifen" for loop, "Skala" for musical scale, Indonesian countdown/count-in mixup, Chinese Alpha badge), grammar bugs (Polish aria-labels requiring an unavailable grammatical case, singular/plural adjective agreement in playlist skip messages), inconsistent terminology within each language, and a stray three-dot ellipsis instead of the single character used everywhere else. Converts playlist.skip.* from t() to plural() with proper singular/plural forms across all seven languages, since Portuguese and Polish adjectives don't inflect correctly as flat strings. --------- Co-authored-by: Thales <> |
||
|
|
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 <> |
||
|
|
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
|
||
|
|
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 <> |
||
|
|
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 <> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 <> |
||
|
|
739128d986 |
feat(desktop): release-notes modal with per-arch download link (#321)
* feat(desktop): release-notes modal with per-arch download link Clicking the "New release available" notification card now opens a settings-style modal showing the GitHub release notes (rendered from a minimal, XSS-safe markdown subset) and a Download button that points at the asset matching the running build. - New Tauri command build_target returns os/arch/gpu so the frontend can pick the exact release asset (macOS keys on arch; Windows/Linux add the .NVIDIA infix for the CUDA variant). Falls back to a navigator OS guess in web/server mode. - renderReleaseNotes handles headings, bold, http(s) links, bullet lists, fenced code blocks, and GitHub blockquote admonitions (the macOS "IMPORTANT" block), escaping first and emitting only whitelisted tags. - Modal reuses the About-dialog styling; the notification card is now click-to-open (badge + card on launch, modal only on click). * chore(unraid): pin template to 0.8.0-alpha.13 * feat(desktop): show docker-pull guidance in server mode In server/Docker mode there is no Tauri, so a per-arch desktop download is meaningless (the client browser's OS/arch has nothing to do with the container, and updates are done by pulling a new image). The release modal now detects server mode and replaces the Download button with the `docker pull ghcr.io/stemdeckapp/stemdeck:<tag>` command plus a note that Unraid users update via the Community Applications template. Desktop mode is unchanged (per-arch download). |
||
|
|
2ea6748bb3 |
feat(player): exact timestamp input for loop start/end (#246) (#252)
* feat(player): exact timestamp input for loop start/end (#246) Add two editable timestamp fields in the transport footer for setting the loop region precisely, alongside the existing drag/click select. Fields display mm:ss.mmm and accept either mm:ss.mmm or plain decimal seconds. - utils.js: fmtTimeMs (integer-ms math, no rounding carry) and parseTimecode (mm:ss.mmm or plain seconds, null on invalid). - transport.js: syncLoopInputs keeps the fields in sync on drag/toggle (never clobbering a field being edited, disabled when no track loaded); commitLoopInput parses, clamps to [0, totalDuration], enforces the MIN_LOOP_SEC ordering, then updates the loop via the existing setters + updateLoopRegionVisual. Enter/blur commit, Escape reverts. Invalid input reverts the field in place (showError belongs to the import form). - player.js: refresh loop UI on track load so the inputs enable + reset once the duration is known. Values flow through the existing loopStart/loopEnd setters and audioEngine.setLoop, so the model and engine are unchanged. * fix(player): place loop time inputs right of the loop button Move the exact loop start/end fields inline into .footer-transport, directly after the loop button, instead of a separate row below the time readout. Drop the redundant LOOP label now that the fields sit next to the loop control. |
||
|
|
2a40dc504e |
feat(player): playback speed control with pitch preservation (#241)
* feat(player): playback speed control (0.5x to 2.0x) Adds a speed slider to the transport footer (desktop) and mixer tab (mobile) so users can slow down or speed up tracks for practice. - audioEngine: store _playbackRate, apply to new AudioBufferSourceNodes on startSources(), and fix getCurrentTime() to account for rate so the waveform playhead and loop detection stay accurate at non-1x speeds - transport: applySpeed() propagates rate to both engine and streaming paths; scroll-wheel support (+-0.25 per tick); double-click resets to 1x - state: playbackSpeed variable + setter; speedEl/speedLabelEl DOM refs - player: resetSpeed() called in destroyPlayer() so a new track always starts at 1x - mobile: speed slider in mixer transport section, state.speed reset on track open * fix(player): move speed control below play button, centered * fix(player): tempo bar full-width below transport, TEMPO label + gold slider * fix(player): center 1.0x on tempo slider (range 0-2, midpoint = 1.0) * feat(audio): pitch-preserving tempo via SoundTouch AudioWorklet Voices and instruments no longer pitch-shift when changing playback speed. A WSOLA time-stretcher runs as an AudioWorkletProcessor on the master bus so a single node handles all stems. Falls back to tape-effect if the worklet API is unavailable. * fix(audio): close array literal in Promise.all ([]) was missing ] |
||
|
|
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 <>
|
||
|
|
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 <> |
||
|
|
e817fa7839 |
feat: add MP4 and M4A upload support, raise limit to 400 MB (#210)
Closes #209 |
||
|
|
907ae7f956 |
feat: expand Supporters (Joao, Kris), Instagram links/avatars, and README We Recommend (#202)
Adds Joao Gaspar and Kris Luthier (with bundled Instagram profile images) to the Supporters dialog; points Dlima Guitars at Instagram. Tiles gain optional role lines, round avatars for IG photos, a small Instagram glyph on IG-linked tiles, and a masonry/tilted layout. Warmer dialog tagline. README gains a We Recommend section with a no-funding disclaimer, and the old donation line now points to it. |
||
|
|
64dc5f7a6d |
feat: Supporters dialog behind a TV rail icon (#197)
A TV icon in the sidebar rail (between Settings and Help) opens an About-style 'Supporters' dialog with partner tiles (Dlima Guitars, Lisbon Guitar Works), clickable to their sites. Logos bundled; rail widened 56->66px so the label fits. Verified headless. |
||
|
|
0a67593ad4 | feat: add FLAC support (import and export) (#flac) (#194) | ||
|
|
d159429c59 |
feat: add Content-Security-Policy to the webview (#171) (#177)
The desktop webview ran with csp:null while withGlobalTauri exposed the Tauri API, so any markup injection could reach Tauri commands. Add a strict CSP as defense-in-depth: - FastAPI sends a CSP response header (the main app — where the library/ folder UI and the window.__TAURI__ surface live — is served by FastAPI at 127.0.0.1, so the header is the effective policy there). script-src 'self' with no unsafe-inline/eval. - Move the inline <script> + 3 inline onclick handlers out of index.html into a new static/js/ui-chrome.js module so the strict policy doesn't break them. - Set a matching CSP for the bundled Tauri setup shell in tauri.conf.json. Styles keep 'unsafe-inline' (the UI sets many style attributes); connect-src allows same-origin API/SSE, the GitHub update check, and Tauri ipc:; img-src allows https: for remote thumbnails. withGlobalTauri kept (disabling it is a larger refactor for marginal gain once the XSS in #170 is fixed + CSP is on). |
||
|
|
0e841669fa |
feat: Edit Library window with out-of-sync detection and auto-restore (#168)
Add a Settings → Edit Library window (centered modal, like the About dialog) listing every library track with its name, source (YouTube/SoundCloud/local file via deriveSource), and location (imported filename or URL), in a scrollable list. Detection + recovery for tracks whose backend audio was swept after the job retention window (the "audio no longer available" case): the reverse of syncWithServer. "Sync again" reconciles the library with GET /api/jobs — adds new server jobs, flags local "done" tracks the server no longer has as "unavailable" (shown red with an out-of-sync count), restores ones that reappeared — then auto re-imports every unavailable URL-sourced track from its source (re-download + re-separate) via the existing studio/SSE pipeline. Restores run sequentially since applyState drives a single active studio job. Local-file imports can't be auto-restored (the original file isn't kept) and stay flagged for manual re-upload. Settings button now opens this window directly; removed the old "coming soon" tooltip + its inline script and CSS. New importFromUrl() in job.js factors the URL-import path for reuse by the restore flow. All new editor code is module-private in catalog.js (reuses tracks/folders/saveState/render/deriveSource/syncWithServer); no new state exports. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
ec292f8806 |
fix: job progress box scoped to waveform panel with unified loading UI
* fix: constrain job progress box to waveform panel area Move #job inside .daw-wave-panel and use inset:0 instead of hardcoded screen coordinates (top:64px left:260px right:0 bottom:56px). Add position:relative to .daw-wave-panel as positioning context. Previously the progress overlay spanned the full app area during file uploads. Now it overlays only the waveform panel, matching the visual behaviour users see with YouTube links. * fix: unify loading UI -- overlay with rotating phrases for both file and YouTube Both file uploads and YouTube links now use wave-loading-overlay (animated bars + rotating phrase) instead of the bare job-box dark card. - wave-loading-overlay phrase is now dynamic (id=waveLoadingPhrase) - startPhraseRotation drives both jobStageEl and the overlay phrase - setWaveformLoading accepts optional initial phrase (shows 'Uploading...' immediately for file uploads while the HTTP transfer is in progress) - job box no longer shown immediately for file uploads -- same overlay-only behaviour as YouTube throughout processing * fix: restore full opacity on loading overlay and job box inside no-track state .daw.no-track dims .daw-content to 0.3 opacity. Since wave-loading-overlay and #job are now inside .daw-content, they were getting faded out during processing. Override opacity:1 and pointer-events:auto for both. |
||
|
|
2dd30207e4 |
fix: use CSS :hover to control settings tooltip visibility
Move tooltip inside the button so CSS handles show/hide via #settingsBtn:hover .settings-coming-soon. position:fixed escapes the sidebar overflow:hidden clip. JS only sets pixel coordinates on mouseenter -- no JS hide logic needed. |
||
|
|
a3efe2cadb |
fix: UI polish -- hover tooltip, waveform resize, single-click load, file re-submit
* fix: settings hover tooltip, waveform resize debounce, single-click track load - Settings button: tooltip now shows on hover (mouseenter/mouseleave) instead of click; tooltip moved outside sidebar to position:fixed so it escapes the sidebar overflow:hidden clip - Waveform resize: ResizeObserver now debounces via requestAnimationFrame so applyWaveZoom runs after layout settles, preventing misaligned zoom on window resize - Track loading: single click now loads the track into studio (removed dblclick handler); drag-and-drop still works as before * fix: settings tooltip hides reliably on mouse-off Remove native title attribute (interferes with mouseleave in WKWebView) and add a document mousemove fallback so the tooltip hides whenever the cursor leaves the button bounds, regardless of event delivery quirks. * fix: Website button only turns yellow on hover in About dialog * fix: all About dialog buttons turn yellow on hover * fix: cache File object on fileInput._file to survive re-submit Browsers (WKWebView, Chromium) silently clear fileInput.files after a fetch() body is consumed, so re-clicking Split on an uploaded file would fall through to the URL path and return 'URL is required' from the backend. Cache the File reference on the element at applyFile time; clear it in clearFile. job.js now reads fileInput._file first, falling back to fileInput.files[0] for the initial selection. |
||
|
|
143ceff743 |
feat: redesign about dialog, live update notifications, settings tooltip
- About dialog: waveform logo, tagline, version pill, Website/GitHub buttons, Discord/Reddit/Instagram/X social icons - Notification panel: hidden by default, shows release card with badge only when GitHub has a newer version; dismiss persists per version - Settings rail button: coming-soon tooltip (fixed position to escape sidebar overflow clip) - checkForUpdate: fix repo URL from thcp to stemdeckapp/stemdeck - README: add X (@StemDeckApp) badge and community table row |
||
|
|
eb3d0c6468 |
fix: stale track load race, all-stems-muted recovery, live update notification
- catalog.js: add load token to abort stale async loads when user switches tracks before fetch completes - mixer.js: scope all-muted recovery to actually loaded stems; add loadedStemNames param so check is not tripped by unloaded stem defaults - player.js: defer applyMix until mix state storage resolves, guarded by jobId and multitrack instance to prevent stale application - index.html + daw.css: notification panel now hidden by default; shows release card with dismiss only when GitHub reports a newer version; badge dot on bell; settings button shows coming-soon tooltip - catalog.js: fix repo URLs from thcp/stemdeck to stemdeckapp/stemdeck; rewrite checkForUpdate to drive notification panel with localStorage dismiss persistence Closes #122 |
||
|
|
fc56651f69 |
feat: collapsible library sidebar, stacked export buttons, VU meter fix
- Add hamburger toggle button to sidebar rail; clicking collapses/expands the sidebar body with a smooth 0.28s width transition - Collapse state persists in localStorage - Stack Export Region below Export Mix as equal-width buttons - Remove Speed button and all dead speed JS from wireFooterControls - Fix VU meter: wavesurfer decode event emits duration, not AudioBuffer; call getDecodedData() directly instead of using the event argument |
||
|
|
513879f66c | fix: move Export Region button after Export Mix in footer | ||
|
|
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 |
||
|
|
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
|
||
|
|
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> |
||
|
|
f08b6367b8 |
feat: macOS native app v0.4.0-alpha.1 (#27)
* ignore build files. * feat: macOS native app — Tauri shell, runtime pack, MPS, CI pipeline - Tauri 2 macOS app with setup wizard that downloads and installs a self-contained Python/backend runtime pack on first launch - Runtime pack download now streams via reqwest with real-time progress events (runtime-download-progress) replacing the blocking curl call; progress bar shown in setup UI with indeterminate fallback - MPS (Apple Silicon) GPU detection and torch device selection; CUDA path gated to non-macOS targets - macOS data dir uses ~/Library/Application Support/StemDeck - macOS FFmpeg downloaded from evermeet.cx and extracted from zip - Backend watchdog: Python process exits when parent Tauri PID disappears - CloseRequested handler stops backend and exits cleanly - Woodpecker CI pipeline for macOS: arm64 and x64 builds in parallel, DMG inspection, artifact upload to GitHub releases - Build scripts: make-runtime-pack.sh, make-app.sh, make-dmg.sh, make-iconset.sh with LOCAL_DEV_TEST version default - Version stamped from CI_COMMIT_TAG (or LOCAL_DEV_TEST) at build time across Cargo.toml, tauri.conf.json, package.json - SVG logo assets, DMG packaging files, macOS README and notices - README updated with macOS download and build instructions - Bump version to 0.4.0-alpha.1 * fix(security): bump urllib3 to 2.7.0, ignore unresolvable torch x86 CVE - urllib3 2.7.0 fixes CVE-2026-44431 (header forwarding) and CVE-2026-44432 (decompression-bomb bypass) - CVE-2025-32434 (torch 2.2.2 RCE via torch.load) added to .trivyignore: no 2.6.x macOS x86_64 wheels exist; StemDeck has no untrusted torch.load path - Wire .trivyignore into the trivy-fs CI step * ci: trigger macOS and Windows builds on tag push and release Adds event: tag so that pushing a version tag (e.g. v0.4.0-alpha.1 for a pre-release) triggers the build and inspect pipeline. Upload steps remain gated on event: release only. * ci: skip asset upload if already present on pre-release promotion When a pre-release is promoted to latest, GitHub fires a second release event. The upload step now checks if assets already exist for the tag and exits early, preventing --clobber from deleting and re-uploading artifacts during the promotion window. |
||
|
|
a9d2302fa0 |
fix(ui): wire collapsed appbar strip and fix version placeholder (#21)
* fix(ui): wire collapsed appbar strip, fix version placeholder - main.js: wire dblclick on .appbar-body to toggle appbar-collapsed; wire strip icon clicks to expand appbar with context actions (url focuses input, process triggers submit, stems just expands); call buildStripStems() on init and after each stem choice toggle so the strip stays in sync - index.html: replace hardcoded v0.1.0 placeholder with … so the stale version never flashes before catalog.js resolves the real version from /api/health - windows-release.yml: add write-version step that writes static/version.json from $CI_COMMIT_TAG before packaging so release builds display the correct version via the existing app_version() → /api/health → setDisplayedVersion pipeline * fix(ui): replace silent 20s waveform timeout with stalled state Large WAV files (6 stems × ~40 MB) take longer to fetch and decode on slower hardware. The previous 20s timeout silently hid the loading overlay before wavesurfer finished rendering, leaving a blank waveform area — the root cause of the reported "waveform not showing" on Windows and the slow-load scenario on Safari. New behaviour: - At 20s without canplay: overlay stays visible but gains .stalled class, showing a "Still loading waveform…" message so the user knows it is still working - At 60s: overlay is hidden as a last resort (unchanged safety net) - On canplay (normal path): overlay hides immediately as before, .stalled class cleared * fix(ui): remove appbar collapse, fix unknown tracks, smooth sidebar - Remove appbar dblclick-collapse behaviour (appbar is not collapsible) - Write metadata.json on job completion so titles survive restarts - Skip title-less jobs on registry restore and localStorage load - Fade+slide sidebar collapse to match main panel transitions * test: update registry tests for metadata.json requirement |
||
|
|
5b051e770a |
feat(appbar): collapsible icon strip, version badge, and UX polish (#12)
* feat: DAW mixer UI, catalog, diagnostics, and UX polish - Horizontal fader mixer (mx-row layout: icon → name → fader → VU → val → M/S/DL) - Solo button with gold active state; mute button stays visible when active - VU meter via pre-computed envelope (no Web Audio analyser dependency) - Catalog panel with job history and quick-load - Auto-collapse import appbar when a track is loaded; "New track" toggle to re-expand - Footer buttons: remove underline, normalize size, add hover state - Transport: restore synchronous play-in-gesture-handler for cross-browser compat - AudioContext resume fire-and-forget before multitrack.play() (Safari-safe) - Loop state reset on new track load; playhead snaps to loopStart on play - Global error/unhandledrejection logging - Per-stem audio element error listeners in canplay handler - State stubs for masterBusGain/masterLimiter (unused, reserved) * feat(appbar): collapsible icon strip, version badge, and UX polish - Appbar collapses to a compact icon strip on double-click using the same grid-template-rows animation as the widget sections; auto-collapses when a track finishes processing - Collapsed strip shows SD monogram, URL icon, per-stem colored squares (active/inactive), and Process button — all clickable - Stem squares toggle selection; Process submits if URL is set, otherwise expands the appbar; SD/URL icon always expands - Brand subtitle shortened to "AI stem separation" - Added v0.1.0 version label with optional green "New release available" chip that fetches the latest tag from the GitHub releases API - Fixed footer button underline and icon sizing * Refine app shell and library interactions * Improve library loading feedback * new image |
||
|
|
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> |
||
|
|
ff5f3f5bce | feat: add Buy Me A Coffee link in README and studio footer (#3) | ||
|
|
fcdde67063 | chore: initial commit |