Skip to content

sa3 LoRA Manager#91

Open
JakeHartnell wants to merge 9 commits into
mainfrom
issue-66-sa3-lora-manager
Open

sa3 LoRA Manager#91
JakeHartnell wants to merge 9 commits into
mainfrom
issue-66-sa3-lora-manager

Conversation

@JakeHartnell

@JakeHartnell JakeHartnell commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Brings custom Stable Audio 3 finetunes into LSDJ end-to-end (ADR-0028, built on
the #44 spike): import them, manage them, and play them — up to four at once,
each with its own strength — from every generate surface. Along the way, our
LoRA runtime contribution merged upstream, so this PR also retires the fork.

Importing & managing adapters

  • LoRA Library section in the settings drawer: import by HuggingFace repo id
    or pasted URL (huggingface.co/… and hf.co/… forms normalize to the
    canonical id), or a local .safetensors via the native file picker.
  • ADR-0028's trust boundary, enforced in the Rust shell: .safetensors only —
    pickle (.ckpt/.pt/.pth/.bin) is refused before any read, and only the
    safetensors JSON header is ever parsed. Both conventions are read (SA3-native
    metadata and PEFT adapter_config.json).
  • The adapter's base DiT is inferred from its tensor shapes (small vs medium
    fan-in widths are disjoint, measured from the runtime); rank-only -xs
    adapters fall back to the PEFT config or an explicit base choice.
  • Registry = the filesystem: sa3-loras/<base>/<slug>/ in the app data dir,
    watched live (hand-dropped folders appear, deletes propagate), surviving
    restarts with nothing else to persist. Install/cancel/progress and delete
    match the Add an in-app model manager: download Magenta + Stable Audio models from a settings overlay #43 model manager, on the same single-install pipeline.

Playing them — the LoRA rack

Every base-matched adapter renders as a toggle chip in a one-line rack strip
(Generate tab, Samples tab, and both deck columns — deck-accented on the decks).
Clicking a chip into the stack grows a trim knob beside it: ×0–×2 in quarter
steps, double-click parks ×1, and ×0 is the bit-exact bypass, shown as a
dimmed slot. Up to four adapters stack per generation; slots stay in library
order because the merge is order-independent. Trims are remembered per adapter
while toggling; a deleted adapter silently drops from the next request.

/api/generate gains loras: [{name, strength}, …] — validated at the trust
boundary (installed-adapter resolution, base-vs-kind match, strength 0–4 bounds,
cap 4, duplicate refusal) and emitted as one --lora <dir> strength=<s> group
per adapter.

Back on upstream

Our MLX merge-at-load runtime landed as Stability-AI/stable-audio-3#57, and #65
extended it with the per-adapter strength= syntax this PR emits — so
sa3-pin.json reverts from the fork to upstream, per ADR-0028's stated exit.
The model manager offers the switch as a normal SA3 update.

Testing

  • Backend: registry trust boundaries, exact argv (verified against upstream's
    parse_lora_spec grammar), and the full /api/generate contract incl.
    boundary/NaN/duplicate/cap rejections.
  • Rust: importer validation, base inference, HF filename constraints, URL
    normalization (needs cargo clippy/cargo test on CI or a Mac — the sandbox
    can't build the shell).
  • Frontend: 593 tests across the rack, library, and all three call sites.
  • Human pass: docs/issue-66-checklist.md — Maqam import, audible effect,
    seed-fixed ×0 hash equality, lifecycle, wrong-base 422, and the new
    multi-adapter section (run the in-app SA3 update first).

JakeHartnell and others added 9 commits July 17, 2026 19:40
The registry read side: lsdj/loras.py resolves a client-supplied
<base>/<slug> adapter name to its on-disk directory (Rust owns the write
side), /api/generate accepts a validated {name, strength} lora object at
the trust boundary, and sa3.generate rides it as --lora/--lora-strength
on the pinned fork's CLI (ADR-0028).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
loras.rs imports adapters from a HuggingFace repo id or a local path on
the shared install thread (model://progress family "lora"), validates
them at the ADR-0028 trust boundary — pickle refused before any read,
safetensors JSON header only, base inferred from the disjoint DiT layer
widths with an explicit choice for rank-only -xs shapes — and places
them under sa3-loras/<base>/<slug>/ with an import manifest. Adapters
ride model_status, get an in-app delete (small, re-downloadable, never
iCloud-managed), and a registry watcher mirrors watch_models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A third model-manager section lists adapters by base with import (HF
repo id or the native file picker, with an explicit-base override for
shape-anonymous -xs adapters) and in-app delete. The media explorer's
generate/samples forms and each deck's pad panel gain a base-matched
adapter picker + strength control that ride the /api/generate lora
field; a stale choice (deleted adapter, base switch) honestly falls
back to none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Import/cancel/refusal, audible adapter effect + strength scaling, the
ADR-0028 bit-exact strength-0 bypass via fixed seed, registry restart/
delete/hand-drop lifecycle, and the wrong-base 422 — the facts only a
real machine with the warmed SA3 checkout can verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The import row crammed the repo field beside the base picker and two
buttons in the narrow drawer; it now wraps, with the field taking the
whole first line. Pasted huggingface.co/hf.co URLs (scheme, /tree/…
suffix, query, fragment) normalize to the canonical owner/name id in
both the webview (so the progress label is canonical) and the shell
(so any caller can paste a link).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The adapter list/import moves out of the Model library umbrella into a
standalone LoraLibrary component with its own top-level settings
heading, so custom sound worlds don't hide inside the weights
installer. It keeps the manager's snapshot/progress contract — one
install at a time shell-side, so another family's download still gates
the import buttons, while only lora events surface as this section's
label and errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The single adapter+strength pickers become the LoraRack: every
base-matched adapter renders as a toggle chip, and a chip clicked into
the stack grows a small trim knob (0-2 in quarter steps, double-click
parks ×1, ×0 dims the slot — the bit-exact bypass, now visible). The
rack replaces the two Selects in all three generate surfaces, taking
the deck accent on the decks and master in the Media Explorer; slots
stay in library order because the merge is order-independent
(ADR-0028). Knob grows a 's' (28 px) dial size and an ariaLabel so the
visible label can be the live ×value readout.

/api/generate's `lora` object becomes `loras`: up to MAX_LORA_STACK
(4) entries, duplicates refused, per-entry strength defaulting to 1.0
so the dirs/strengths argv lists stay aligned. sa3.generate emits one
--lora list plus one --lora-strength value per adapter.

Per-adapter strength needs a small fork change — the pinned CLI takes
multiple --lora but a single global strength. docs/sa3-lora-stack.patch
carries it (single value still broadcasts, so existing invocations are
untouched); apply + push + sa3-pin.json bump before hardware checks,
per the new checklist section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ADR-0028's revert condition arrived: our merge-at-load contribution
merged upstream 2026-07-14 (Stability-AI/stable-audio-3#57), and #65
superseded the fork's flag shape with per-adapter --lora groups
(PATH strength=S [steps=RANGE]). The pin moves to upstream main
(0385302); the model manager offers the switch as a normal update
(repo drift already counts as updatable).

sa3.generate now emits one --lora group per adapter with its
strength= option instead of parallel dir/strength lists, verified
against upstream's parse_lora_spec grammar. docs/sa3-lora-stack.patch
is gone — upstream already does what it patched in, better — and the
checklist's fork gate becomes "run the in-app SA3 update".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stacked slot grew downward (chip over an inset trim well), costing
~90px of the Media Explorer tray whenever anything was stacked — height
that belongs to the library list. The slot is now a strip: trim knob
beside the chip behind a hairline divider, the ×value readout beside
the dial (the knob lies flat via the rack's trim styles, with a floor
width on the readout so a drag from ×1 to ×0.25 doesn't shuffle the
neighbouring slots), and the silkscreen label inline. Same grammar —
chip toggles, knob trims, ×0 dims — at control height.

The rack also owns its empty state now: it renders nothing without
adapters, so the call sites drop their length guards (the deck keeps
only its magenta gate). Its cap likewise counts only present slots, so
a stale stack entry can't hold a slot hostage after a delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@brxs
brxs self-requested a review July 18, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant