// AdaptersDropdown.tsx — Adapter configuration UI for the global param bar // // Adapted from create/AdaptersAccordion.tsx to read from GlobalParamsContext. // Self-contained with Simple and Advanced modes, file browser, group scales. import React, { useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { FolderOpen, X, Tag, Search, Circle, ChevronDown, RotateCcw } from 'lucide-react'; import { useGlobalParams } from '../../context/GlobalParamsContext'; import { usePersistedState } from '../../hooks/usePersistedState'; import { adapterApi, modelApi } from '../../services/api'; import { FileBrowserModal } from '../shared/FileBrowserModal'; import { Slider } from '../shared/Slider'; import { ModelSelect, getModelFormat } from './ModelSelect'; import { formatDitModel } from './modelLabels'; import { DEFAULT_SETTINGS, type AppSettings } from '../settings/SettingsPanel'; import type { AdapterFile } from '../../types'; // Select styling applied inline where needed const GROUP_INFO = [ { key: 'self_attn' as const, label: 'Self-Attn', help: 'How audio frames relate to each other over time' }, { key: 'cross_attn' as const, label: 'Cross-Attn', help: 'How strongly your text prompt shapes the output' }, { key: 'mlp' as const, label: 'MLP', help: 'Timbre, tonal texture, and sonic character' }, { key: 'cond_embed' as const, label: 'Conditioning', help: 'How the adapter reshapes text/style interpretation' }, { key: 'time_embed' as const, label: 'Timestep', help: 'How the adapter modifies noise-schedule understanding (0 = skip)' }, { key: 'proj_in' as const, label: 'Proj-In', help: 'Input patchification layer — how latent tokens enter the model (0 = skip)' }, ]; function deriveTriggerWord(adapterPath: string): string { if (!adapterPath) return ''; const filename = adapterPath.split(/[\\/]/).pop() || ''; return filename.replace(/\.safetensors$/i, ''); } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } export const AdaptersDropdown: React.FC = () => { const gp = useGlobalParams(); const { t } = useTranslation(); const [settings] = usePersistedState('ace-settings', DEFAULT_SETTINGS); // Internal state const [adapterFiles, setAdapterFiles] = useState([]); const [showGroupScales, setShowGroupScales] = usePersistedState('hs-adapterAccordion-groupScales', false); const [fileBrowserOpen, setFileBrowserOpen] = useState(false); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); const [ditModels, setDitModels] = useState([]); // DiT model list for the basin re-base "home base" selector. useEffect(() => { modelApi.list().then(m => setDitModels(m?.models?.dit || [])).catch(() => {}); }, []); // Planner-LM adapters (runtime LoRA on the 5Hz LM). Filesystem-scanned via // the Node route so freshly trained adapters appear WITHOUT an engine // restart — selection sends the absolute path, which the engine's // path-fallback resolver loads directly. const [lmAdapters, setLmAdapters] = useState<{ name: string; path: string; kind: string; size: number; lmSize?: string; run?: string }[]>([]); const refreshLmAdapters = useCallback(() => { adapterApi.lmList(gp.lmAdapterFolder || undefined).then(r => setLmAdapters(r?.adapters || [])).catch(() => {}); }, [gp.lmAdapterFolder]); useEffect(() => { refreshLmAdapters(); }, [refreshLmAdapters]); const fileBrowserMode = gp.advancedAdapters ? 'folder' as const : 'file' as const; // In advanced mode the stack drives everything; in simple mode the single adapter does. const stack: { path: string; scale: number; stepStart?: number; stepEnd?: number }[] = gp.adapterStack || []; // Any stack entry with a timestep window forces runtime mode server-side — // several knobs below change visibility/meaning when this is true. const stackHasWindows = stack.some(e => e.stepStart !== undefined || e.stepEnd !== undefined); // Timestep window helpers. Store fields stepStart/stepEnd are flow-matching t // (1 = noise, 0 = clean); the UI shows "% of denoising" (0% = first step), // so display = (1 − t) flipped: startPct derives from stepEnd and vice versa. const winStartPct = (e: { stepEnd?: number }) => Math.round((1 - (e.stepEnd ?? 1)) * 100); const winEndPct = (e: { stepStart?: number }) => Math.round((1 - (e.stepStart ?? 0)) * 100); const setWindowPct = (path: string, sPct: number, ePct: number) => { const lo = Math.max(0, Math.min(100, Math.min(sPct, ePct))); const hi = Math.max(0, Math.min(100, Math.max(sPct, ePct))); gp.setAdapterStackWindow(path, 1 - hi / 100, 1 - lo / 100); }; const primaryPath = gp.advancedAdapters ? (stack[0]?.path || '') : gp.adapter; const hasAdapter = gp.advancedAdapters ? stack.length > 0 : !!gp.adapter; const triggerWord = deriveTriggerWord(primaryPath); // Every stacked adapter contributes its trigger word (matches what is injected // into the caption server-side). const stackTriggerWords = stack.map(e => deriveTriggerWord(e.path)).filter(Boolean).join(', '); const adapterFilename = gp.adapter ? gp.adapter.split(/[\\/]/).pop() || '' : ''; const fileLabel = (p: string) => p.split(/[\\/]/).pop() || p; // Blend mode: per-adapter sliders are relative weights, normalised so the // effective scales sum to the combined-strength budget. effectiveScale maps a // row's weight to the scale actually sent to the engine (mirrors the store). const isBlend = gp.adapterStackMode === 'blend'; // Sum/Blend distinction only matters with 2+ adapters; a single adapter just // has a "Strength". const multiStack = stack.length >= 2; const stackWeightSum = stack.reduce((acc, e) => acc + (e.scale || 0), 0); const effectiveScale = (weight: number) => { if (!isBlend || !multiStack) return weight; const budget = gp.adapterStackBudget ?? 0.75; return stackWeightSum > 0 ? (budget * (weight || 0)) / stackWeightSum : budget / Math.max(1, stack.length); }; const GROUP_DEFAULTS: Record = { self_attn: 1.0, cross_attn: 1.0, mlp: 1.0, cond_embed: 1.0, time_embed: 0.0, proj_in: 0.0 }; const allDefault = GROUP_INFO.every(g => gp.adapterGroupScales[g.key] === (GROUP_DEFAULTS[g.key] ?? 1.0)); const handleGroupScaleChange = (key: keyof typeof gp.adapterGroupScales, value: number) => { gp.setAdapterGroupScales({ ...gp.adapterGroupScales, [key]: value }); }; const handleScan = useCallback(async (folder?: string) => { const dir = folder || gp.adapterFolder; if (!dir) return; setScanning(true); setScanError(null); try { const result = await adapterApi.scan(dir); setAdapterFiles(result.files); if (result.files.length === 0) { setScanError('No .safetensors files found in this folder'); } } catch (err: any) { setScanError(err?.message || 'Failed to scan folder'); } finally { setScanning(false); } }, [gp.adapterFolder]); const handleBrowseSelect = (path: string) => { setFileBrowserOpen(false); if (gp.advancedAdapters) { gp.setAdapterFolder(path); handleScan(path); } else { gp.setAdapter(path); } }; return (
{/* Simple / Advanced toggle */}
{/* ═══ SIMPLE MODE ═══ */} {!gp.advancedAdapters && ( <>
gp.setAdapter(e.target.value)} placeholder="Path to .safetensors file..." className="flex-1 px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors font-mono text-xs" />
{gp.adapter && (
{adapterFilename}
)} {gp.adapter && settings.triggerUseFilename && triggerWord && (
{triggerWord} ({settings.triggerPlacement})
)} )} {/* ═══ ADVANCED MODE ═══ */} {gp.advancedAdapters && ( <>
gp.setAdapterFolder(e.target.value)} placeholder="Path to folder with adapters..." className="flex-1 px-3 py-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-600 focus:border-pink-500/50 focus:ring-1 focus:ring-pink-500/20 outline-none transition-colors font-mono text-xs" />
{scanError && (
{scanError}
)} {adapterFiles.length > 0 && (
{adapterFiles.map((file) => { const isActive = stack.some(a => a.path === file.path); return ( ); })}
)} {/* Selected stack: one row per adapter with its own scale + remove. */} {stack.length > 0 && (
{t('adapter.adapterStack', 'Adapter Stack')} ({stack.length})
{/* Sum / Blend toggle — only meaningful with 2+ adapters */} {multiStack && ( <>

{isBlend ? 'Per-adapter sliders are relative weights; effective scales are normalised so they sum to the combined strength below. Keeps total strength constant as you add adapters.' : 'Per-adapter sliders are absolute scales, summed directly. Σ can exceed 1 to deliberately over-drive the stack.'}

{/* Combined strength budget (blend mode only) */} {isBlend && ( )} )} {stack.map((entry, i) => (
{i + 1}. {fileLabel(entry.path)} {multiStack && isBlend && ( → {effectiveScale(entry.scale).toFixed(3)} )}
gp.setAdapterStackScale(entry.path, v)} min={0} max={4} step={0.05} showInput /> {/* Timestep window (interval experts): which slice of denoising this adapter is active in. 0% = first step (structure), 100% = last (texture/detail). Any non-full window forces runtime mode. */}
{t('adapter.timestepWindow', 'Active phase')} setWindowPct(entry.path, Number(e.target.value), winEndPct(entry))} className="w-12 px-1 py-0.5 text-[10px] text-right rounded bg-white dark:bg-black/30 border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-300" /> setWindowPct(entry.path, winStartPct(entry), Number(e.target.value))} className="w-12 px-1 py-0.5 text-[10px] text-right rounded bg-white dark:bg-black/30 border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-300" /> % {(entry.stepStart !== undefined || entry.stepEnd !== undefined) && ( )}
))} {stackHasWindows && (

{t('adapter.timestepWindowVram', 'Timestep windows force Runtime mode: each adapter holds its own full-size deltas in VRAM. Set Adapter VRAM below to Q8 ½ or Q4 ¼ to keep this affordable.')}

)} {settings.triggerUseFilename && stackTriggerWords && (
{stackTriggerWords} ({settings.triggerPlacement})
)} {/* Per-section masking hint (2+ adapters) */} {multiStack && (
Per-section influence

Vary each adapter by lyric section — add a directive after a section header, keyed by trigger word. Forces runtime mode.

{`[Verse]{${stackTriggerWords.split(', ').map((w, i) => `${w}=${i === 0 ? '1' : '0'}`).join('; ')}}
[Chorus]{${stackTriggerWords.split(', ').map((w, i) => `${w}=${i === 0 ? '0' : '1'}`).join('; ')}}`}

When section boundaries snap to the model's real timing. Earlier locks section identity sooner (less first-adapter bias); too early = fuzzier boundaries.

)}
)} )} {/* ═══ PLANNER ADAPTER (LM) — song-structure LoRA on the 5Hz planner ═══ */}
Planner Adapter (LM)

Artist-trained song-structure adapter applied to the planner LM at runtime. Pairs with the matching DiT adapter (timbre) — same trigger word.

gp.setLmAdapterFolder(e.target.value)} placeholder="Scan folder (empty = adapters/lm)" className="w-full px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-white/10 text-[11px] text-zinc-700 dark:text-zinc-300 placeholder-zinc-500 focus:border-violet-500/50 outline-none transition-colors" /> {lmAdapters.length === 0 ? (

None found in adapters/lm — train one with Side-Step's lm-train.

) : (
{lmAdapters.map((a) => { const isActive = gp.lmAdapter === a.path; return ( ); })}
)} {gp.lmAdapter && (
{lmAdapters.find(a => a.path === gp.lmAdapter)?.name || gp.lmAdapter}
)} {/* Global planner strength — ALWAYS visible: like the DiT Adapter Scale, it also governs planner adapters supplied by Album Presets. */}

Applies to the adapter above AND album-preset planner adapters. 1.0 = as trained; above ~1.4 risks repetitive planning — prefer more training epochs over slider overdrive.

{/* ═══ SHARED CONTROLS (when adapter selected) ═══ */} {hasAdapter && ( <> {/* Adapter Scale — simple mode only; the advanced stack has per-row scales */} {!gp.advancedAdapters && ( )} {/* Loading Mode */}

{gp.adapterMode === 'runtime_lowrank' ? 'Applies raw adapter factors per-step, never materializing full deltas — lowest VRAM (LoRA & LoKr; DoRA needs Merge). Basin re-base still works.' : gp.adapterMode === 'runtime' ? 'Keeps base weights intact, applies adapter per-step. Same quality, slower inference, saves VRAM.' : 'Merges adapter at F32 precision. Best quality, fast inference, but uses more VRAM during synthesis.'}

{/* Merge VRAM — merge mode only (storage precision of the merged weights) */} {gp.adapterMode === 'merge' && (
{([ { v: false, label: 'HQ', sub: 'Merged weights stored as F32 (best quality, ~4× VRAM on a Q8 base)' }, { v: true, label: 'Low ¼', sub: 'Merged weights re-encoded to the base\'s native quant' }, ] as const).map(opt => ( ))}

HQ keeps merged weights at F32 (a Q8 base grows ~4× in VRAM). Low re-encodes them back to the base's native quant — base-model VRAM, one extra quantization round-trip. FP4 bases always use the low path.

)} {/* Adapter Quantization — runtime modes (quantizes the in-VRAM full-size deltas; in Low-Rank mode that's the re-base correction + Conv1d fallbacks). Also shown when timestep windows are set: windows force runtime mode server-side, so this knob governs VRAM even from Merge/Low-Rank. */} {(gp.adapterMode === 'runtime' || gp.adapterMode === 'runtime_lowrank' || stackHasWindows) && (
{([ { v: 'bf16', label: 'Full', sub: 'BF16' }, { v: 'q8_0', label: 'Q8 ½', sub: 'Q8_0' }, { v: 'q4_0', label: 'Q4 ¼', sub: 'Q4_0' }, ] as const).map(opt => ( ))}

Quantizes the runtime adapter deltas in VRAM (nothing written to disk). Q8 halves / Q4 quarters VRAM per adapter — lets more stacked adapters fit. Small quality cost; safe when the base model is already 4-bit (NVFP4).

)} {/* Basin re-base (cross-base adapter support) — merge AND runtime modes (runtime folds the nudge into the delta sum; per-section masking skips it). Home base must be a SafeTensors model (nudge reads F32 weights), so the selector is filtered to safetensors DiT models only. */}
Basin Re-base {gp.rebaseSource && }
{gp.rebaseSource && ( )}
getModelFormat(m) === 'safetensors')} formatLabel={formatDitModel} placeholder="Off — apply adapter as-is" /> {gp.rebaseSource && ( <>

Nudges the loaded base toward the adapter's home base so a heavy cross-base adapter stays coherent at full strength. β=1 = home-base behavior; lower keeps more of the loaded base's character.

)}
{/* Group Scales */} {showGroupScales && (
{t('adapter.layerScales')}
{GROUP_INFO.map(({ key, label, help }) => (
handleGroupScaleChange(key, v)} min={0} max={4} step={0.05} showInput />

{help}

))}
)} )} {/* File Browser Modal */} setFileBrowserOpen(false)} onSelect={handleBrowseSelect} mode={fileBrowserMode} startPath={gp.advancedAdapters ? gp.adapterFolder : undefined} filter="adapters" title={gp.advancedAdapters ? t('adapter.selectAdapterFolder') : t('adapter.selectAdapterFile')} />
); }; /** Summary badge for the Adapters section */ export const AdaptersBadge: React.FC = () => { const { adapter, adapterScale, adapterStack, advancedAdapters } = useGlobalParams(); const stack = adapterStack || []; const useStack = advancedAdapters && stack.length > 0; const shortName = (p: string) => p.split(/[\\/]/).pop()?.replace(/\.safetensors$/i, '') || ''; if (useStack) { const first = shortName(stack[0].path); return (
a.path).join('\n')}> {first} {stack.length > 1 && +{stack.length - 1}}
); } const filename = adapter ? shortName(adapter) : ''; return (
{adapter ? ( <> {filename} ×{adapterScale.toFixed(2)} ) : ( None )}
); };